feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions
@@ -0,0 +1,70 @@
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
interface DateRangeSelectorProps {
range: TimeRange;
groupBy: GroupBy;
onRangeChange: (r: TimeRange) => void;
onGroupByChange: (g: GroupBy) => void;
}
function SegmentedControl<T extends string>({
label,
options,
value,
onChange,
formatLabel,
}: {
label: string;
options: T[];
value: T;
onChange: (v: T) => void;
formatLabel?: (v: T) => string;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1">{label}</span>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{options.map((opt) => (
<button
key={opt}
onClick={() => onChange(opt)}
aria-pressed={value === opt}
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
value === opt
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{formatLabel ? formatLabel(opt) : opt}
</button>
))}
</div>
</div>
);
}
export function DateRangeSelector({
range,
groupBy,
onRangeChange,
onGroupByChange,
}: DateRangeSelectorProps) {
return (
<div className="flex items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
value={range}
onChange={onRangeChange}
formatLabel={(r) => (r === 'all' ? 'All' : r)}
/>
<SegmentedControl
label="Group by"
options={['day', 'month'] as GroupBy[]}
value={groupBy}
onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
/>
</div>
);
}
@@ -0,0 +1,133 @@
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
export interface PerAnimeDataPoint {
epochDay: number;
animeTitle: string;
value: number;
}
interface StackedTrendChartProps {
title: string;
data: PerAnimeDataPoint[];
colorPalette?: string[];
}
const DEFAULT_LINE_COLORS = [
'#8aadf4',
'#c6a0f6',
'#a6da95',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
function buildLineData(raw: PerAnimeDataPoint[]) {
const totalByAnime = new Map<string, number>();
for (const entry of raw) {
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
}
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
const topTitles = sorted.slice(0, 7).map(([title]) => title);
const topSet = new Set(topTitles);
const byDay = new Map<number, Record<string, number>>();
for (const entry of raw) {
if (!topSet.has(entry.animeTitle)) continue;
const row = byDay.get(entry.epochDay) ?? {};
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
byDay.set(entry.epochDay, row);
}
const points = [...byDay.entries()]
.sort(([a], [b]) => a - b)
.map(([epochDay, values]) => {
const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
};
for (const title of topTitles) {
row[title] = values[title] ?? 0;
}
return row;
});
return { points, seriesKeys: topTitles };
}
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data);
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
const tooltipStyle = {
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
if (points.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<div className="text-xs text-ctp-overlay2">No data</div>
</div>
);
}
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={points}>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} />
{seriesKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={0.15}
strokeWidth={1.5}
connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
{seriesKeys.map((key, i) => (
<span
key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colors[i % colors.length] }}
/>
<span className="truncate">{key}</span>
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
interface TrendChartProps {
title: string;
data: Array<{ label: string; value: number }>;
color: string;
type: 'bar' | 'line';
formatter?: (value: number) => string;
onBarClick?: (label: string) => void;
}
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
const tooltipStyle = {
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={120}>
{type === 'bar' ? (
<BarChart data={data}>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Bar
dataKey="value"
fill={color}
radius={[2, 2, 0, 0]}
cursor={onBarClick ? 'pointer' : undefined}
onClick={
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
}
/>
</BarChart>
) : (
<LineChart data={data}>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
</LineChart>
)}
</ResponsiveContainer>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
{children}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
);
}
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
}
function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
onShowAll,
onHideAll,
onToggleAnime,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
}
return (
<div className="col-span-full -mt-1 mb-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
Anime Visibility
</h4>
<p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onShowAll}
>
All
</button>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-peach hover:text-ctp-peach"
onClick={onHideAll}
>
None
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{animeTitles.map((title) => {
const isVisible = !hiddenAnime.has(title);
return (
<button
key={title}
type="button"
aria-pressed={isVisible}
className={`max-w-full rounded-full border px-3 py-1 text-xs transition ${
isVisible
? 'border-ctp-blue/60 bg-ctp-blue/12 text-ctp-blue'
: 'border-ctp-surface2 bg-transparent text-ctp-subtext0'
}`}
onClick={() => onToggleAnime(title)}
title={title}
>
<span className="block truncate">{title}</span>
</button>
);
})}
</div>
</div>
);
}
export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
const { data, loading, error } = useTrends(range, groupBy);
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [
cardsMinedColor,
'#8aadf4',
'#c6a0f6',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data) return null;
const animeTitles = buildAnimeVisibilityOptions([
data.animePerDay.episodes,
data.animePerDay.watchTime,
data.animePerDay.cards,
data.animePerDay.words,
data.animePerDay.lookups,
data.animeCumulative.episodes,
data.animeCumulative.cards,
data.animeCumulative.words,
data.animeCumulative.watchTime,
]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const filteredEpisodesPerAnime = filterHiddenAnimeData(
data.animePerDay.episodes,
activeHiddenAnime,
);
const filteredWatchTimePerAnime = filterHiddenAnimeData(
data.animePerDay.watchTime,
activeHiddenAnime,
);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(
data.animePerDay.lookups,
activeHiddenAnime,
);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
);
const filteredAnimeProgress = filterHiddenAnimeData(
data.animeCumulative.episodes,
activeHiddenAnime,
);
const filteredCardsProgress = filterHiddenAnimeData(
data.animeCumulative.cards,
activeHiddenAnime,
);
const filteredWordsProgress = filterHiddenAnimeData(
data.animeCumulative.words,
activeHiddenAnime,
);
const filteredWatchTimeProgress = filterHiddenAnimeData(
data.animeCumulative.watchTime,
activeHiddenAnime,
);
return (
<div className="space-y-4">
<DateRangeSelector
range={range}
groupBy={groupBy}
onRangeChange={setRange}
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.activity.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Cards Mined"
data={data.activity.cards}
color={cardsMinedColor}
type="bar"
/>
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Period Trends</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart
title="New Words Seen"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Anime Per Day</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
onShowAll={() => setHiddenAnime(new Set())}
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) =>
setHiddenAnime((current) => {
const next = new Set(current);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
return next;
})
}
/>
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart
title="Cards Mined per Anime"
data={filteredCardsPerAnime}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
<StackedTrendChart
title="Lookups/100w per Anime"
data={filteredLookupsPerHundredPerAnime}
/>
<SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart
title="Cards Mined Progress"
data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={data.patterns.watchTimeByDayOfWeek}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={data.patterns.watchTimeByHour}
color="#c6a0f6"
type="bar"
/>
</div>
</div>
);
}
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 6 },
{ epochDay: 1, animeTitle: 'Trapped in a Dating Sim', value: 20 },
];
test('buildAnimeVisibilityOptions sorts anime by combined contribution', () => {
const titles = buildAnimeVisibilityOptions([
SAMPLE_POINTS,
[
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 8 },
{ epochDay: 1, animeTitle: 'KonoSuba', value: 1 },
],
]);
assert.deepEqual(titles, ['Trapped in a Dating Sim', 'KonoSuba', 'Little Witch Academia']);
});
test('filterHiddenAnimeData removes globally hidden anime from chart data', () => {
const filtered = filterHiddenAnimeData(SAMPLE_POINTS, new Set(['KonoSuba']));
assert.equal(
filtered.some((point) => point.animeTitle === 'KonoSuba'),
false,
);
assert.equal(filtered.length, 2);
});
test('pruneHiddenAnime drops titles that are no longer available', () => {
const hidden = pruneHiddenAnime(new Set(['KonoSuba', 'Ghost in the Shell']), [
'KonoSuba',
'Little Witch Academia',
]);
assert.deepEqual([...hidden], ['KonoSuba']);
});
@@ -0,0 +1,32 @@
import type { PerAnimeDataPoint } from './StackedTrendChart';
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>();
for (const dataset of datasets) {
for (const point of dataset) {
totals.set(point.animeTitle, (totals.get(point.animeTitle) ?? 0) + point.value);
}
}
return [...totals.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([title]) => title);
}
export function filterHiddenAnimeData(
data: PerAnimeDataPoint[],
hiddenAnime: ReadonlySet<string>,
): PerAnimeDataPoint[] {
if (hiddenAnime.size === 0) {
return data;
}
return data.filter((point) => !hiddenAnime.has(point.animeTitle));
}
export function pruneHiddenAnime(
hiddenAnime: ReadonlySet<string>,
availableAnime: readonly string[],
): Set<string> {
const availableSet = new Set(availableAnime);
return new Set([...hiddenAnime].filter((title) => availableSet.has(title)));
}