From c4e28f2d777cd9e997baa635c4df82cee84f9fbe Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 20 Mar 2026 21:14:12 +0200 Subject: [PATCH] constructor analytics --- .../src/components/AnalyticsDashboard.tsx | 949 ++++++++++++++++++ 1 file changed, 949 insertions(+) create mode 100644 frontend/src/components/AnalyticsDashboard.tsx diff --git a/frontend/src/components/AnalyticsDashboard.tsx b/frontend/src/components/AnalyticsDashboard.tsx new file mode 100644 index 0000000..f0ae746 --- /dev/null +++ b/frontend/src/components/AnalyticsDashboard.tsx @@ -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 + solve_history: SolveEntry[] + activity_calendar: Record + 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 + solve_time_distribution: number[] + monthly_totals: MonthlyEntry[] + completion_rate: number + words_encountered: number + avg_time_by_hour: Record + 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 ( + + {label && ( + {label} + )} + {yLabels.map((yl, i) => ( + + + {formatY(yl.val)} + + ))} + + + + + + + + + {pts.map((p, i) => ( + + ))} + {pts.filter((_, i) => i % Math.ceil(data.length / 6) === 0 || i === data.length - 1).map((p, i) => ( + + {p.lbl || ''} + + ))} + + ) +} + +// 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 ( + + {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 ( + + + {labels[i] && ( + + {labels[i]} + + )} + {formatTooltip && val > 0 && ( + {formatTooltip(val)} + )} + + ) + })} + + ) +} + +// 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 ( + + {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 ( + + + {i % 2 === 0 && ( + + {fmtTime(Math.round(rangeStart))} + + )} + {val > 0 && ( + + {val} + + )} + {fmtTime(Math.round(rangeStart))} - {fmtTime(Math.round(rangeEnd))}: {val} solve{val !== 1 ? 's' : ''} + + ) + })} + + ) +} + +// 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 ( +
+ + {arcs.map((a, i) => ( + + {a.label}: {a.value} ({Math.round(a.pct * 100)}%) + + ))} + {total} + {t('analytics.total')} + +
+ {arcs.map((a, i) => ( +
+ + {a.label} + {a.value} +
+ ))} +
+
+ ) +} + +// 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 ( +
+ + + {value > 0 && ( + + )} + {Math.round(value)}% + {t('analytics.completion')} + +
+ ) +} + +// Activity heatmap +function ActivityHeatmap({ calendar }: { calendar: Record }) { + 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 ( +
+ + {months.map((m, i) => ( + {m.label} + ))} + {dayLabels.map((lbl, i) => ( + lbl ? {lbl} : null + ))} + {days.map((d, i) => ( + 0 ? 'var(--accent)' : 'var(--surface)'} + opacity={d.count > 0 ? opacity(d.count) : 1} + rx={1}> + {d.date}: {d.count} solve{d.count !== 1 ? 's' : ''} + + ))} + +
+ {totalSolves !== 1 ? t('analytics.solvesInYear_plural', { count: totalSolves }) : t('analytics.solvesInYear', { count: totalSolves })} +
+
+ ) +} + +const presetOrder = ['glyph', 'rune', 'scroll', 'codex', 'grimoire'] +const presetNames = { + glyph: 'Glyph', rune: 'Rune', scroll: 'Scroll', codex: 'Codex', grimoire: 'Grimoire' +} satisfies Record + +function presetNameFor(names: Record, 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(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 ( + + 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 */} +
+
+

+ + {t('analytics.title')} +

+ +
+
+ + {loading && ( +
{t('common.loading')}
+ )} + + {error && ( +
{t('analytics.couldNotLoad')}
+ )} + + {data && summary && summary.total_solves === 0 && ( +
+ +
{t('analytics.noPuzzlesSolved')}
+
+ )} + + {data && summary && summary.total_solves > 0 && ( + <> + {/* Row 1: summary cards (full width) */} +
+
+ {[ + { 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) => ( + +
+ {card.icon && } + {card.value} +
+
{card.label}
+
+ ))} +
+
+ + {/* Row 2: activity heatmap (full width) */} + +
{t('analytics.activity')}
+ +
+ + {/* Row 3: improvement + completion rate gauge - always render both */} +
+ +
{t('analytics.improvement')}
+
+ {data.improvement_rate != null ? (<> +
+ {data.improvement_rate <= 0 ? ( + + ) : ( + + )} + {Math.abs(data.improvement_rate).toFixed(1)}% +
+
+ {data.improvement_rate <= 0 + ? t('analytics.fasterThanLastMonth', { pct: Math.abs(data.improvement_rate).toFixed(1) }) + : t('analytics.slowerThanLastMonth', { pct: data.improvement_rate.toFixed(1) })} +
+ ) : ( +
+ - + {t('analytics.needMoreSolves')} +
+ )} +
+
+ +
{t('analytics.completionRate')}
+ +
+
+ + {/* Row 4: solve time trend (full width) */} + {timeTrendData.length >= 2 && ( + +
{t('analytics.solveTimeTrend')}
+ fmtTime(Math.round(v))} label="time" /> +
+ )} + + {/* Row 5: time of day + consistency+day stacked - always render both */} +
+ +
{t('analytics.timeOfDay')}
+ +
+
+ +
{t('analytics.consistency')}
+ {(() => { + const times = (data.solve_history || []).map(s => s.time).filter(t => t > 0) + if (times.length < 2) return
-{t('analytics.notEnoughData')}
+ 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 ( +
+ {Math.round(cv)}% + {t('analytics.variance')} - {rating} +
+ ) + })()} +
+ +
{t('analytics.dayOfWeek')}
+ +
+
+
+ + {/* Row 6: preset breakdown + preset donut */} +
+ +
{t('analytics.byPreset')}
+
+ {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 ( +
+ {presetNameFor(presetNames, k)} +
+
+
+ {p.count} + best {fmtTime(p.best)} +
+ ) + })} +
+ + +
{t('analytics.presetMix')}
+
+ {presetDonutData.length > 0 + ? + :
{t('analytics.notEnoughDataYet')}
} +
+
+
+ + {/* Row 7: hints trend + clean solve trend */} +
+ +
{t('analytics.hintsTrend')}
+ {hintsTrendData.length >= 2 + ? v.toFixed(1)} label="hints" /> + :
{t('analytics.notEnoughDataYet')}
} +
+ +
{t('analytics.cleanSolveTrend')}
+ {cleanRateTrendData.length >= 2 + ? `${Math.round(v)}%`} label="clean" maxYOverride={100} /> + :
{t('analytics.notEnoughDataYet')}
} +
+
+ + {/* Row 8: monthly volume + solve time distribution - always both */} +
+ +
{t('analytics.monthlyVolume')}
+ {data.monthly_totals && data.monthly_totals.length > 0 + ? m.count)} labels={data.monthly_totals.map(m => fmtMonth(m.month))} width={280} height={120} highlightMax /> + :
{t('analytics.notEnoughDataYet')}
} +
+ +
{t('analytics.solveTimeDistribution')}
+ {data.solve_time_distribution && data.solve_time_distribution.some(v => v > 0) + ? + :
{t('analytics.notEnoughDataYet')}
} +
+
+ + {/* Row 9: difficulty progression (full width) */} + {diffProgressionData.length >= 2 && ( + +
{t('analytics.difficultyProgression')}
+ { + const names = ['Glyph','Rune','Scroll','Codex','Grimoire'] + const idx = Math.round(v) + return names[Math.min(idx, 4)] || '' + }} + label="difficulty" maxYOverride={4.5} /> +
+ )} + + {/* Row 10: peak performance + streak progress - always both */} +
+ +
{t('analytics.peakPerformance')}
+
+ {(() => { + 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 ( + + + {t('analytics.fastestAt', { hour: best.i, time: fmtTime(best.v) })} + + ) + })()} +
+ `avg ${fmtTime(v)}`} + /> +
+ +
{t('analytics.streakProgress')}
+
+
+
+
+
+ + + {summary.current_streak !== 1 ? t('analytics.daysCurrent_plural', { count: summary.current_streak }) : t('analytics.daysCurrent', { count: summary.current_streak })} + + + + {summary.best_streak !== 1 ? t('analytics.daysBest_plural', { count: summary.best_streak }) : t('analytics.daysBest', { count: summary.best_streak })} + +
+ {summary.current_streak >= summary.best_streak && summary.current_streak > 0 && ( +
+ + {t('analytics.bestStreakNow')} +
+ )} +
+ +
+ + {/* Row 11: personal records (full width) */} + {data.fastest_by_preset && Object.keys(data.fastest_by_preset).length > 0 && ( + +
{t('analytics.personalRecords')}
+
+ {presetOrder.filter(k => data.fastest_by_preset[k]).map((k, i) => ( + +
+ +
+
{presetNameFor(presetNames, k)}
+
{fmtTime(data.fastest_by_preset[k].time)}
+
{fmtDate(data.fastest_by_preset[k].date)}
+
+ ))} +
+
+ )} + + {/* Row 12: recent solves (full width) */} + {data.solve_history && data.solve_history.length > 0 && ( + +
{t('analytics.recentSolves')}
+
+ {data.solve_history.slice(0, 20).map((s, i) => ( +
+ {fmtDate(s.date)} + {presetNameFor(presetNames, s.preset)} + + {fmtTime(s.time)} + + {s.hints > 0 && ( + + {s.hints} + + )} +
+ ))} +
+
+ )} + + )} +
+ + ) +}