constructor analytics
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user