feat(stats): speed up session maintenance and improve stats UI (#111)

This commit is contained in:
2026-06-08 02:20:52 -07:00
committed by GitHub
parent e6a16a069b
commit 311f1e8ee5
108 changed files with 7441 additions and 729 deletions
@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import type { KanjiEntry } from '../../types/stats';
import { formatNumber } from '../../lib/formatters';
interface KanjiBreakdownProps {
kanji: KanjiEntry[];
@@ -6,34 +8,75 @@ interface KanjiBreakdownProps {
onSelectKanji?: (entry: KanjiEntry) => void;
}
// Heat scale from rare (cool) to very frequent (warm). Catppuccin Macchiato.
const FREQ_TIERS = [
{ min: 0.85, color: 'text-ctp-peach', swatch: 'bg-ctp-peach', label: 'Very frequent' },
{ min: 0.6, color: 'text-ctp-yellow', swatch: 'bg-ctp-yellow', label: 'Frequent' },
{ min: 0.35, color: 'text-ctp-green', swatch: 'bg-ctp-green', label: 'Common' },
{ min: 0.15, color: 'text-ctp-teal', swatch: 'bg-ctp-teal', label: 'Occasional' },
{ min: 0, color: 'text-ctp-subtext0', swatch: 'bg-ctp-subtext0', label: 'Rare' },
] as const;
function tierFor(intensity: number) {
return FREQ_TIERS.find((tier) => intensity >= tier.min) ?? FREQ_TIERS[FREQ_TIERS.length - 1]!;
}
export function KanjiBreakdown({
kanji,
selectedKanjiId = null,
onSelectKanji,
}: KanjiBreakdownProps) {
if (kanji.length === 0) return null;
const { totalOccurrences, maxLogFreq } = useMemo(() => {
let total = 0;
let maxFreq = 1;
for (const entry of kanji) {
total += entry.frequency;
if (entry.frequency > maxFreq) maxFreq = entry.frequency;
}
return { totalOccurrences: total, maxLogFreq: Math.log(maxFreq + 1) };
}, [kanji]);
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
if (kanji.length === 0) return null;
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-ctp-text">
Kanji Encountered
<span className="ml-2 font-normal text-ctp-subtext0">
{formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen
</span>
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-ctp-subtext0">
<span>rare</span>
<div className="flex items-center gap-1">
{[...FREQ_TIERS].reverse().map((tier) => (
<span
key={tier.label}
className={`h-2 w-2 rounded-full ${tier.swatch}`}
title={tier.label}
/>
))}
</div>
<span>frequent</span>
</div>
</div>
<div className="flex flex-wrap gap-1">
{kanji.map((k) => {
const ratio = k.frequency / maxFreq;
const opacity = Math.max(0.3, ratio);
// Log scale keeps the heavily-skewed frequency distribution readable.
const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0;
const tier = tierFor(intensity);
const selected = selectedKanjiId === k.kanjiId;
return (
<button
type="button"
key={k.kanji}
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
selectedKanjiId === k.kanjiId
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
: 'hover:bg-ctp-surface1/80'
className={`cursor-pointer rounded-md px-1.5 py-0.5 text-xl leading-none font-medium transition-colors duration-150 ${tier.color} ${
selected ? 'bg-ctp-surface2 ring-1 ring-ctp-lavender' : 'hover:bg-ctp-surface1'
}`}
style={{ opacity }}
title={`${k.kanji} — seen ${k.frequency}x`}
title={`${k.kanji} — seen ${formatNumber(k.frequency)}×`}
aria-label={`${k.kanji} — seen ${k.frequency} times`}
aria-pressed={selected}
onClick={() => onSelectKanji?.(k)}
>
{k.kanji}