constructor analytics

This commit is contained in:
2026-03-20 21:14:12 +02:00
parent 4e9ec58a5d
commit c4e28f2d77
@@ -0,0 +1,949 @@
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { motion } from 'motion/react'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconX, IconFlameFilled, IconChartAreaLineFilled,
IconClockFilled, IconBulbFilled, IconPuzzleFilled,
IconCircleArrowDownFilled, IconCircleArrowUpFilled,
IconTrophyFilled, IconRosetteDiscountCheckFilled,
IconStarFilled } from '@tabler/icons-react'
import * as api from '../api'
interface Summary {
total_solves: number
clean_solves: number
total_time_secs: number
avg_time_secs: number
avg_hints: number
current_streak: number
best_streak: number
first_solve: string | null
puzzles_rated: number
clues_submitted: number
}
interface SolveEntry {
date: string
preset: string
time: number
hints: number
}
interface TrendEntry {
date: string
avg_time?: number
avg_hints?: number
}
interface PresetStats {
count: number
best: number
avg: number
clean: number
}
interface CleanRateEntry {
date: string
rate: number
}
interface MonthlyEntry {
month: string
count: number
}
interface DiffEntry {
date: string
avg_difficulty: number
}
interface FastestRecord {
time: number
date: string
}
interface Analytics {
summary: Summary
by_preset: Record<string, PresetStats>
solve_history: SolveEntry[]
activity_calendar: Record<string, number>
by_hour: number[]
by_day_of_week: number[]
time_trend: TrendEntry[]
hints_trend: TrendEntry[]
improvement_rate: number | null
clean_rate_trend: CleanRateEntry[]
fastest_by_preset: Record<string, FastestRecord>
solve_time_distribution: number[]
monthly_totals: MonthlyEntry[]
completion_rate: number
words_encountered: number
avg_time_by_hour: Record<string, number>
difficulty_progression: DiffEntry[]
}
function fmtTime(secs: number): string {
if (secs <= 0) return '--:--'
if (secs >= 3600) {
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
return `${h}h ${m.toString().padStart(2, '0')}m`
}
const m = Math.floor(secs / 60)
const s = Math.round(secs % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function fmtDate(d: string): string {
const dt = new Date(d + 'T00:00:00')
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function fmtTotalTime(secs: number): string {
if (secs < 60) return `${Math.round(secs)}s`
if (secs < 3600) return `${Math.round(secs / 60)}m`
const h = Math.floor(secs / 3600)
const m = Math.round((secs % 3600) / 60)
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
function fmtMonth(m: string): string {
const [, mo] = m.split('-')
const names = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
return names[parseInt(mo, 10) - 1] || mo
}
// Catmull-Rom spline to SVG bezier path
function smoothPath(points: {x: number, y: number}[]): string {
if (points.length < 2) return ''
let d = `M ${points[0].x} ${points[0].y}`
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(i - 1, 0)]
const p1 = points[i]
const p2 = points[i + 1]
const p3 = points[Math.min(i + 2, points.length - 1)]
const cp1x = p1.x + (p2.x - p0.x) / 6
const cp1y = p1.y + (p2.y - p0.y) / 6
const cp2x = p2.x - (p3.x - p1.x) / 6
const cp2y = p2.y - (p3.y - p1.y) / 6
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
}
return d
}
// smooth area path (closes to baseline)
function smoothArea(points: {x: number, y: number}[], baseline: number): string {
if (points.length < 2) return ''
const curve = smoothPath(points)
return `${curve} L ${points[points.length - 1].x} ${baseline} L ${points[0].x} ${baseline} Z`
}
// SVG smooth line chart with area fill
function SmoothLineChart({ data, width, height, formatY, label, maxYOverride }: {
data: { x: number; y: number; label?: string }[]
width: number
height: number
formatY: (v: number) => string
label?: string
maxYOverride?: number
}) {
if (data.length < 2) return null
const pad = { top: 16, right: 8, bottom: 24, left: 42 }
const w = width - pad.left - pad.right
const h = height - pad.top - pad.bottom
const minY = 0
const maxY = maxYOverride || (Math.max(...data.map(d => d.y)) * 1.1 || 1)
const pts = data.map((d, i) => ({
x: pad.left + (i / (data.length - 1)) * w,
y: pad.top + h - ((d.y - minY) / (maxY - minY)) * h,
lbl: d.label,
}))
const curvePath = smoothPath(pts)
const areaPath = smoothArea(pts, pad.top + h)
const yTicks = 3
const yLabels = Array.from({ length: yTicks + 1 }, (_, i) => {
const val = minY + ((maxY - minY) / yTicks) * i
return { val, y: pad.top + h - (i / yTicks) * h }
})
const gradId = `smooth-grad-${label || 'default'}`
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg">
{label && (
<text x={pad.left} y={10} className="analytics-chart-label">{label}</text>
)}
{yLabels.map((yl, i) => (
<g key={i}>
<line x1={pad.left} y1={yl.y} x2={width - pad.right} y2={yl.y}
stroke="var(--border)" strokeWidth={0.5} />
<text x={pad.left - 6} y={yl.y + 3} textAnchor="end"
className="analytics-chart-tick">{formatY(yl.val)}</text>
</g>
))}
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.2" />
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradId})`} />
<path d={curvePath} fill="none" stroke="var(--accent)" strokeWidth={1.5} />
{pts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={2.5}
fill="var(--bg)" stroke="var(--accent)" strokeWidth={1.5} />
))}
{pts.filter((_, i) => i % Math.ceil(data.length / 6) === 0 || i === data.length - 1).map((p, i) => (
<text key={i} x={p.x} y={pad.top + h + 14}
textAnchor="middle" className="analytics-chart-tick">
{p.lbl || ''}
</text>
))}
</svg>
)
}
// SVG vertical bar chart
function BarChart({ data, labels, width, height, highlightMax, highlightMin, formatTooltip }: {
data: number[]
labels: string[]
width: number
height: number
highlightMax?: boolean
highlightMin?: boolean
formatTooltip?: (v: number) => string
}) {
if (!data.length) return null
const pad = { top: 8, right: 4, bottom: 20, left: 4 }
const w = width - pad.left - pad.right
const h = height - pad.top - pad.bottom
const maxVal = Math.max(...data) || 1
const nonZero = data.filter(v => v > 0)
const minNonZero = nonZero.length ? Math.min(...nonZero) : 0
const peakIdx = highlightMax ? data.indexOf(maxVal) : -1
const lowIdx = highlightMin && minNonZero > 0 ? data.indexOf(minNonZero) : -1
const barW = Math.min(w / data.length * 0.7, 20)
const gap = w / data.length
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg">
{data.map((val, i) => {
const barH = (val / maxVal) * h
const x = pad.left + i * gap + (gap - barW) / 2
const y = pad.top + h - barH
const isPeak = i === peakIdx && val > 0
const isLow = i === lowIdx && val > 0
return (
<g key={i}>
<rect x={x} y={y} width={barW} height={barH}
fill={isPeak ? 'var(--accent)' : isLow ? '#3b82f6' : 'var(--text)'}
opacity={isPeak || isLow ? 0.9 : 0.15} />
{labels[i] && (
<text x={x + barW / 2} y={pad.top + h + 13}
textAnchor="middle" className="analytics-chart-tick">
{labels[i]}
</text>
)}
{formatTooltip && val > 0 && (
<title>{formatTooltip(val)}</title>
)}
</g>
)
})}
</svg>
)
}
// Histogram chart with labeled buckets
function Histogram({ buckets, minVal, maxVal, width, height }: {
buckets: number[]
minVal: number
maxVal: number
width: number
height: number
}) {
if (!buckets.length || buckets.every(b => b === 0)) return null
const pad = { top: 8, right: 8, bottom: 28, left: 8 }
const w = width - pad.left - pad.right
const h = height - pad.top - pad.bottom
const peak = Math.max(...buckets) || 1
const bucketRange = (maxVal - minVal) / buckets.length
const barW = (w / buckets.length) * 0.8
const gap = w / buckets.length
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} className="analytics-chart-svg">
{buckets.map((val, i) => {
const barH = (val / peak) * h
const x = pad.left + i * gap + (gap - barW) / 2
const y = pad.top + h - barH
const rangeStart = minVal + i * bucketRange
const rangeEnd = rangeStart + bucketRange
return (
<g key={i}>
<rect x={x} y={y} width={barW} height={Math.max(barH, 0)}
fill="var(--accent)" opacity={0.15 + (val / peak) * 0.65} />
{i % 2 === 0 && (
<text x={x + barW / 2} y={pad.top + h + 12}
textAnchor="middle" className="analytics-chart-tick">
{fmtTime(Math.round(rangeStart))}
</text>
)}
{val > 0 && (
<text x={x + barW / 2} y={y - 3}
textAnchor="middle" className="analytics-chart-tick" style={{ fontSize: '5.5px' }}>
{val}
</text>
)}
<title>{fmtTime(Math.round(rangeStart))} - {fmtTime(Math.round(rangeEnd))}: {val} solve{val !== 1 ? 's' : ''}</title>
</g>
)
})}
</svg>
)
}
// Donut/pie chart
function DonutChart({ segments, size }: {
segments: { label: string; value: number; color: string }[]
size: number
}) {
const { t } = useTranslation()
const total = segments.reduce((a, s) => a + s.value, 0)
if (total === 0) return null
const r = size / 2 - 4
const cx = size / 2
const cy = size / 2
const strokeW = r * 0.35
const innerR = r - strokeW / 2
const circumference = 2 * Math.PI * innerR
const arcs = segments.filter(s => s.value > 0).map((s, i, arr) => {
const pct = s.value / total
const dash = pct * circumference
const offset = arr.slice(0, i).reduce((sum, prev) => sum + prev.value / total * circumference, 0)
const rotation = offset / circumference * 360 - 90
return { ...s, pct, dash, offset, rotation }
})
return (
<div className="analytics-donut">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{arcs.map((a, i) => (
<circle key={i} cx={cx} cy={cy} r={innerR}
fill="none" stroke={a.color} strokeWidth={strokeW}
strokeDasharray={`${a.dash} ${circumference - a.dash}`}
strokeDashoffset={-a.offset}
transform={`rotate(-90 ${cx} ${cy})`}>
<title>{a.label}: {a.value} ({Math.round(a.pct * 100)}%)</title>
</circle>
))}
<text x={cx} y={cy - 4} textAnchor="middle"
className="analytics-donut-total">{total}</text>
<text x={cx} y={cy + 8} textAnchor="middle"
className="analytics-donut-label">{t('analytics.total')}</text>
</svg>
<div className="analytics-donut-legend">
{arcs.map((a, i) => (
<div key={i} className="analytics-donut-legend-item">
<span className="analytics-donut-swatch" style={{ background: a.color }} />
<span className="analytics-donut-legend-name">{a.label}</span>
<span className="analytics-donut-legend-val">{a.value}</span>
</div>
))}
</div>
</div>
)
}
// Semicircular gauge
function GaugeChart({ value, size }: { value: number; size: number }) {
const { t } = useTranslation()
const cx = size / 2
const cy = size * 0.6
const r = size * 0.4
const startAngle = Math.PI
const endAngle = 0
const sweep = startAngle - endAngle
const angle = startAngle - (value / 100) * sweep
const arcPath = (start: number, end: number) => {
const x1 = cx + r * Math.cos(start)
const y1 = cy + r * Math.sin(start)
const x2 = cx + r * Math.cos(end)
const y2 = cy + r * Math.sin(end)
const largeArc = Math.abs(end - start) > Math.PI ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
}
return (
<div className="analytics-gauge">
<svg width={size} height={size * 0.7} viewBox={`0 0 ${size} ${size * 0.7}`}>
<path d={arcPath(startAngle, endAngle)} fill="none"
stroke="var(--border)" strokeWidth={8} strokeLinecap="round" />
{value > 0 && (
<path d={arcPath(startAngle, angle)} fill="none"
stroke="var(--accent)" strokeWidth={8} strokeLinecap="round" />
)}
<text x={cx} y={cy - 6} textAnchor="middle"
className="analytics-gauge-value">{Math.round(value)}%</text>
<text x={cx} y={cy + 8} textAnchor="middle"
className="analytics-gauge-label">{t('analytics.completion')}</text>
</svg>
</div>
)
}
// Activity heatmap
function ActivityHeatmap({ calendar }: { calendar: Record<string, number> }) {
const { t } = useTranslation()
const cellSize = 10
const cellGap = 2
const total = cellSize + cellGap
const today = new Date()
const days: { date: string; count: number; col: number; row: number }[] = []
// build 365 days backwards
for (let i = 364; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().split('T')[0]
const dayOfWeek = (d.getDay() + 6) % 7 // Mon=0
const daysFromEnd = i
const col = 52 - Math.floor(daysFromEnd / 7)
days.push({ date: key, count: calendar[key] || 0, col, row: dayOfWeek })
}
const totalSolves = Object.values(calendar).reduce((a, b) => a + b, 0)
// month labels
const months: { label: string; col: number }[] = []
let lastMonth = -1
for (const d of days) {
const month = new Date(d.date + 'T00:00:00').getMonth()
if (month !== lastMonth) {
months.push({ label: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month], col: d.col })
lastMonth = month
}
}
const dayLabels = ['Mo', '', 'We', '', 'Fr', '', '']
const svgW = 53 * total + 24
const svgH = 7 * total + 22
function opacity(count: number): number {
if (count === 0) return 0
if (count === 1) return 0.2
if (count <= 3) return 0.4
if (count === 4) return 0.8
return 1
}
return (
<div>
<svg width="100%" viewBox={`0 0 ${svgW} ${svgH}`} className="analytics-chart-svg">
{months.map((m, i) => (
<text key={i} x={24 + m.col * total} y={8}
className="analytics-chart-tick">{m.label}</text>
))}
{dayLabels.map((lbl, i) => (
lbl ? <text key={i} x={18} y={16 + i * total + 8}
textAnchor="end" className="analytics-chart-tick">{lbl}</text> : null
))}
{days.map((d, i) => (
<rect key={i}
x={24 + d.col * total} y={14 + d.row * total}
width={cellSize} height={cellSize}
fill={d.count > 0 ? 'var(--accent)' : 'var(--surface)'}
opacity={d.count > 0 ? opacity(d.count) : 1}
rx={1}>
<title>{d.date}: {d.count} solve{d.count !== 1 ? 's' : ''}</title>
</rect>
))}
</svg>
<div className="analytics-heatmap-total">
{totalSolves !== 1 ? t('analytics.solvesInYear_plural', { count: totalSolves }) : t('analytics.solvesInYear', { count: totalSolves })}
</div>
</div>
)
}
const presetOrder = ['glyph', 'rune', 'scroll', 'codex', 'grimoire']
const presetNames = {
glyph: 'Glyph', rune: 'Rune', scroll: 'Scroll', codex: 'Codex', grimoire: 'Grimoire'
} satisfies Record<string, string>
function presetNameFor(names: Record<string, string>, key: string): string {
return names[key] ?? key
}
export default function AnalyticsDashboard({ onClose }: { onClose: () => void }) {
const trapRef = useFocusTrap(true)
const { t } = useTranslation()
const [data, setData] = useState<Analytics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
api.fetchAnalytics()
.then(d => { setData(d); setLoading(false) })
.catch(() => { setError(true); setLoading(false) })
}, [])
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const timeTrendData = useMemo(() => {
if (!data?.time_trend) return []
return data.time_trend.map((t, i) => ({
x: i, y: t.avg_time || 0, label: fmtDate(t.date)
}))
}, [data])
const hintsTrendData = useMemo(() => {
if (!data?.hints_trend) return []
return data.hints_trend.map((t, i) => ({
x: i, y: t.avg_hints || 0, label: fmtDate(t.date)
}))
}, [data])
const cleanRateTrendData = useMemo(() => {
if (!data?.clean_rate_trend?.length) return []
return data.clean_rate_trend.map((t, i) => ({
x: i, y: t.rate, label: fmtDate(t.date)
}))
}, [data])
const diffProgressionData = useMemo(() => {
if (!data?.difficulty_progression?.length) return []
return data.difficulty_progression.map((t, i) => ({
x: i, y: t.avg_difficulty, label: fmtDate(t.date)
}))
}, [data])
const presetDonutData = useMemo(() => {
if (!data?.by_preset) return []
const colors = ['var(--accent)', 'rgba(212,67,47,0.7)', 'rgba(212,67,47,0.45)', 'rgba(212,67,47,0.25)', 'rgba(212,67,47,0.12)']
return presetOrder
.filter(k => data.by_preset[k]?.count > 0)
.map((k, i) => ({
label: presetNameFor(presetNames, k),
value: data.by_preset[k].count,
color: colors[i] || colors[colors.length - 1],
}))
}, [data])
const avgTimeByHourData = useMemo(() => {
if (!data?.avg_time_by_hour) return { data: [], labels: [] }
const arr = Array.from({ length: 24 }, (_, i) => data.avg_time_by_hour[String(i)] || 0)
const labels = Array.from({ length: 24 }, (_, i) => i % 3 === 0 ? String(i) : '')
return { data: arr, labels }
}, [data])
// solve time distribution range
const distRange = useMemo(() => {
if (!data?.solve_history?.length) return { min: 0, max: 0 }
const times = data.solve_history.map(s => s.time).filter(t => t > 0)
if (!times.length) return { min: 0, max: 0 }
return { min: Math.min(...times), max: Math.max(...times) }
}, [data])
const hourLabels = Array.from({ length: 24 }, (_, i) => i % 3 === 0 ? String(i) : '')
// SAFETY: i18n's returnObjects contract returns an array for the dayNames key
const dayLabels = t('analytics.dayNames', { returnObjects: true }) as string[]
const presetMax = useMemo(() => {
if (!data?.by_preset) return 1
return Math.max(...presetOrder.map(k => data.by_preset[k]?.count || 0), 1)
}, [data])
const summary = data?.summary
return (
<motion.div
className="settings-overlay"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<motion.div
ref={trapRef}
className="analytics-modal"
onClick={e => e.stopPropagation()}
role="dialog" aria-modal="true" aria-label={t('analytics.title')}
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
{/* header */}
<div className="analytics-header">
<div className="analytics-title-row">
<h3 className="analytics-title">
<IconChartAreaLineFilled size={18} style={{ verticalAlign: -3, marginRight: 6 }} />
{t('analytics.title')}
</h3>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
</div>
</div>
{loading && (
<div className="analytics-empty">{t('common.loading')}</div>
)}
{error && (
<div className="analytics-empty">{t('analytics.couldNotLoad')}</div>
)}
{data && summary && summary.total_solves === 0 && (
<div className="analytics-empty">
<IconPuzzleFilled size={32} style={{ color: 'var(--text-tertiary)', marginBottom: 8 }} />
<div>{t('analytics.noPuzzlesSolved')}</div>
</div>
)}
{data && summary && summary.total_solves > 0 && (
<>
{/* Row 1: summary cards (full width) */}
<div className="analytics-section">
<div className="analytics-cards">
{[
{ value: String(summary.total_solves), label: t('analytics.solved') },
{ value: summary.total_solves > 0 ? Math.round((summary.clean_solves / summary.total_solves) * 100) + '%' : '0%', label: t('analytics.cleanRate') },
{ value: fmtTotalTime(summary.total_time_secs), label: t('analytics.totalTime') },
{ value: String(summary.current_streak), label: t('analytics.streak'), icon: true },
{ value: String(summary.best_streak), label: t('analytics.bestStreak') },
{ value: fmtTime(summary.avg_time_secs), label: t('analytics.avgTime') },
].map((card, i) => (
<motion.div
key={i}
className="analytics-card"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.25 }}
>
<div className="analytics-card-value">
{card.icon && <IconFlameFilled size={14} style={{ color: 'var(--accent)', verticalAlign: -2, marginRight: 2 }} />}
{card.value}
</div>
<div className="analytics-card-label">{card.label}</div>
</motion.div>
))}
</div>
</div>
{/* Row 2: activity heatmap (full width) */}
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.activity')}</div>
<ActivityHeatmap calendar={data.activity_calendar || {}} />
</motion.div>
{/* Row 3: improvement + completion rate gauge - always render both */}
<div className="analytics-grid-2">
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.improvement')}</div>
<div className="analytics-improvement" style={{ minHeight: '7rem', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{data.improvement_rate != null ? (<>
<div className={`analytics-improvement-value ${data.improvement_rate <= 0 ? 'positive' : 'negative'}`}>
{data.improvement_rate <= 0 ? (
<IconCircleArrowDownFilled size={20} style={{ marginRight: 6 }} />
) : (
<IconCircleArrowUpFilled size={20} style={{ marginRight: 6 }} />
)}
{Math.abs(data.improvement_rate).toFixed(1)}%
</div>
<div className="analytics-improvement-label">
{data.improvement_rate <= 0
? t('analytics.fasterThanLastMonth', { pct: Math.abs(data.improvement_rate).toFixed(1) })
: t('analytics.slowerThanLastMonth', { pct: data.improvement_rate.toFixed(1) })}
</div>
</>) : (
<div className="analytics-mini-stat">
<span className="analytics-mini-val">-</span>
<span className="analytics-mini-label">{t('analytics.needMoreSolves')}</span>
</div>
)}
</div>
</motion.div>
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.completionRate')}</div>
<GaugeChart value={data.completion_rate || 0} size={160} />
</motion.div>
</div>
{/* Row 4: solve time trend (full width) */}
{timeTrendData.length >= 2 && (
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.solveTimeTrend')}</div>
<SmoothLineChart data={timeTrendData} width={560} height={140}
formatY={v => fmtTime(Math.round(v))} label="time" />
</motion.div>
)}
{/* Row 5: time of day + consistency+day stacked - always render both */}
<div className="analytics-grid-2">
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.timeOfDay')}</div>
<BarChart data={data.by_hour || Array(24).fill(0)} labels={hourLabels}
width={280} height={100} highlightMax />
</motion.div>
<div className="analytics-stacked">
<motion.div className="analytics-section analytics-section-compact"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.38, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.consistency')}</div>
{(() => {
const times = (data.solve_history || []).map(s => s.time).filter(t => t > 0)
if (times.length < 2) return <div className="analytics-mini-stat"><span className="analytics-mini-val">-</span><span className="analytics-mini-label">{t('analytics.notEnoughData')}</span></div>
const avg = times.reduce((a, b) => a + b, 0) / times.length
const variance = times.reduce((a, t) => a + Math.pow(t - avg, 2), 0) / times.length
const stdDev = Math.sqrt(variance)
const cv = (stdDev / avg) * 100
const rating = cv < 15 ? t('analytics.veryConsistent') : cv < 30 ? t('analytics.consistent') : cv < 50 ? t('analytics.variable') : t('analytics.unpredictable')
return (
<div className="analytics-mini-stat">
<span className="analytics-mini-val">{Math.round(cv)}%</span>
<span className="analytics-mini-label">{t('analytics.variance')} - {rating}</span>
</div>
)
})()}
</motion.div>
<motion.div className="analytics-section analytics-section-compact"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.dayOfWeek')}</div>
<BarChart data={data.by_day_of_week || Array(7).fill(0)} labels={dayLabels}
width={280} height={80} highlightMax />
</motion.div>
</div>
</div>
{/* Row 6: preset breakdown + preset donut */}
<div className="analytics-grid-2">
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.byPreset')}</div>
<div className="analytics-presets">
{presetOrder.filter(k => data.by_preset[k]).map((k, i) => {
const p = data.by_preset[k]
const pct = (p.count / presetMax) * 100
const opacities = [0.9, 0.7, 0.5, 0.35, 0.2]
return (
<div key={k} className="analytics-preset-row">
<span className="analytics-preset-name">{presetNameFor(presetNames, k)}</span>
<div className="analytics-preset-bar-wrap">
<div className="analytics-preset-bar"
style={{ width: `${Math.max(pct, 2)}%`, opacity: opacities[i] || 0.5 }} />
</div>
<span className="analytics-preset-count">{p.count}</span>
<span className="analytics-preset-best">best {fmtTime(p.best)}</span>
</div>
)
})}
</div>
</motion.div>
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.presetMix')}</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{presetDonutData.length > 0
? <DonutChart segments={presetDonutData} size={160} />
: <div className="analytics-mini-stat"><span className="analytics-mini-label">{t('analytics.notEnoughDataYet')}</span></div>}
</div>
</motion.div>
</div>
{/* Row 7: hints trend + clean solve trend */}
<div className="analytics-grid-2">
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.55, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.hintsTrend')}</div>
{hintsTrendData.length >= 2
? <SmoothLineChart data={hintsTrendData} width={280} height={140} formatY={v => v.toFixed(1)} label="hints" />
: <div className="analytics-mini-stat"><span className="analytics-mini-label">{t('analytics.notEnoughDataYet')}</span></div>}
</motion.div>
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.cleanSolveTrend')}</div>
{cleanRateTrendData.length >= 2
? <SmoothLineChart data={cleanRateTrendData} width={280} height={140} formatY={v => `${Math.round(v)}%`} label="clean" maxYOverride={100} />
: <div className="analytics-mini-stat"><span className="analytics-mini-label">{t('analytics.notEnoughDataYet')}</span></div>}
</motion.div>
</div>
{/* Row 8: monthly volume + solve time distribution - always both */}
<div className="analytics-grid-2">
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.65, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.monthlyVolume')}</div>
{data.monthly_totals && data.monthly_totals.length > 0
? <BarChart data={data.monthly_totals.map(m => m.count)} labels={data.monthly_totals.map(m => fmtMonth(m.month))} width={280} height={120} highlightMax />
: <div className="analytics-mini-stat"><span className="analytics-mini-label">{t('analytics.notEnoughDataYet')}</span></div>}
</motion.div>
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.solveTimeDistribution')}</div>
{data.solve_time_distribution && data.solve_time_distribution.some(v => v > 0)
? <Histogram buckets={data.solve_time_distribution} minVal={distRange.min} maxVal={distRange.max} width={280} height={120} />
: <div className="analytics-mini-stat"><span className="analytics-mini-label">{t('analytics.notEnoughDataYet')}</span></div>}
</motion.div>
</div>
{/* Row 9: difficulty progression (full width) */}
{diffProgressionData.length >= 2 && (
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.75, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.difficultyProgression')}</div>
<SmoothLineChart data={diffProgressionData} width={560} height={140}
formatY={v => {
const names = ['Glyph','Rune','Scroll','Codex','Grimoire']
const idx = Math.round(v)
return names[Math.min(idx, 4)] || ''
}}
label="difficulty" maxYOverride={4.5} />
</motion.div>
)}
{/* Row 10: peak performance + streak progress - always both */}
<div className="analytics-grid-2">
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.peakPerformance')}</div>
<div className="analytics-peak-hint">
{(() => {
const nonZero = avgTimeByHourData.data
.map((v, i) => ({ v, i }))
.filter(x => x.v > 0)
if (!nonZero.length) return null
const best = nonZero.reduce((a, b) => a.v < b.v ? a : b)
return (
<span>
<IconStarFilled size={12} style={{ verticalAlign: -2, marginRight: 4, color: 'var(--accent)' }} />
{t('analytics.fastestAt', { hour: best.i, time: fmtTime(best.v) })}
</span>
)
})()}
</div>
<BarChart
data={avgTimeByHourData.data}
labels={avgTimeByHourData.labels}
width={280} height={100}
highlightMin
formatTooltip={v => `avg ${fmtTime(v)}`}
/>
</motion.div>
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.85, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.streakProgress')}</div>
<div className="analytics-streak-bar">
<div className="analytics-streak-track">
<div className="analytics-streak-fill"
style={{ width: `${Math.min((summary.current_streak / summary.best_streak) * 100, 100)}%` }} />
</div>
<div className="analytics-streak-labels">
<span>
<IconFlameFilled size={11} style={{ verticalAlign: -2, marginRight: 3, color: 'var(--accent)' }} />
{summary.current_streak !== 1 ? t('analytics.daysCurrent_plural', { count: summary.current_streak }) : t('analytics.daysCurrent', { count: summary.current_streak })}
</span>
<span>
<IconRosetteDiscountCheckFilled size={11} style={{ verticalAlign: -2, marginRight: 3 }} />
{summary.best_streak !== 1 ? t('analytics.daysBest_plural', { count: summary.best_streak }) : t('analytics.daysBest', { count: summary.best_streak })}
</span>
</div>
{summary.current_streak >= summary.best_streak && summary.current_streak > 0 && (
<div className="analytics-streak-record">
<IconStarFilled size={12} style={{ verticalAlign: -2, marginRight: 4 }} />
{t('analytics.bestStreakNow')}
</div>
)}
</div>
</motion.div>
</div>
{/* Row 11: personal records (full width) */}
{data.fastest_by_preset && Object.keys(data.fastest_by_preset).length > 0 && (
<motion.div className="analytics-section"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.personalRecords')}</div>
<div className="analytics-record-cards">
{presetOrder.filter(k => data.fastest_by_preset[k]).map((k, i) => (
<motion.div key={k} className="analytics-record-card"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 + i * 0.05, duration: 0.2 }}>
<div className="analytics-record-icon">
<IconTrophyFilled size={14} />
</div>
<div className="analytics-record-preset">{presetNameFor(presetNames, k)}</div>
<div className="analytics-record-time">{fmtTime(data.fastest_by_preset[k].time)}</div>
<div className="analytics-record-date">{fmtDate(data.fastest_by_preset[k].date)}</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Row 12: recent solves (full width) */}
{data.solve_history && data.solve_history.length > 0 && (
<motion.div className="analytics-section analytics-section-last"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.95, duration: 0.3 }}>
<div className="analytics-section-title">{t('analytics.recentSolves')}</div>
<div className="analytics-recent">
{data.solve_history.slice(0, 20).map((s, i) => (
<div key={i} className="analytics-recent-row">
<span className="analytics-recent-date">{fmtDate(s.date)}</span>
<span className="analytics-recent-preset">{presetNameFor(presetNames, s.preset)}</span>
<span className="analytics-recent-time">
<IconClockFilled size={10} /> {fmtTime(s.time)}
</span>
{s.hints > 0 && (
<span className="analytics-recent-hints">
<IconBulbFilled size={10} /> {s.hints}
</span>
)}
</div>
))}
</div>
</motion.div>
)}
</>
)}
</motion.div>
</motion.div>
)
}