mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
feat: improve stats dashboard and annotation settings
This commit is contained in:
@@ -7,52 +7,64 @@ interface DateRangeSelectorProps {
|
||||
onGroupByChange: (g: GroupBy) => void;
|
||||
}
|
||||
|
||||
export function DateRangeSelector({
|
||||
range,
|
||||
groupBy,
|
||||
onRangeChange,
|
||||
onGroupByChange,
|
||||
}: DateRangeSelectorProps) {
|
||||
const ranges: TimeRange[] = ['7d', '30d', '90d', 'all'];
|
||||
const groups: GroupBy[] = ['day', 'month'];
|
||||
|
||||
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-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Range</span>
|
||||
{ranges.map((r) => (
|
||||
<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={r}
|
||||
onClick={() => onRangeChange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`px-2.5 py-1 rounded text-xs ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{r === 'all' ? 'All' : r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-ctp-surface2">{'\u00B7'}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Group by</span>
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => onGroupByChange(g)}
|
||||
aria-pressed={groupBy === g}
|
||||
className={`px-2.5 py-1 rounded text-xs capitalize ${
|
||||
groupBy === g
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{g}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
@@ -39,10 +39,15 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
|
||||
|
||||
const points = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([epochDay, values]) => ({
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
...values,
|
||||
}));
|
||||
.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 };
|
||||
}
|
||||
@@ -67,31 +72,36 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
||||
<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}>
|
||||
<LineChart data={points}>
|
||||
<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) => (
|
||||
<Line
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={LINE_COLORS[i % LINE_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
fill={LINE_COLORS[i % LINE_COLORS.length]}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
<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">
|
||||
<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"
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
|
||||
/>
|
||||
{key}
|
||||
<span className="truncate">{key}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -32,18 +32,32 @@ function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
|
||||
|
||||
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
|
||||
const byAnime = new Map<string, Map<number, number>>();
|
||||
const allDays = new Set<number>();
|
||||
for (const p of points) {
|
||||
const dayMap = byAnime.get(p.animeTitle) ?? new Map();
|
||||
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
|
||||
byAnime.set(p.animeTitle, dayMap);
|
||||
allDays.add(p.epochDay);
|
||||
}
|
||||
|
||||
const sortedDays = [...allDays].sort((a, b) => a - b);
|
||||
if (sortedDays.length < 2) return points;
|
||||
|
||||
const minDay = sortedDays[0]!;
|
||||
const maxDay = sortedDays[sortedDays.length - 1]!;
|
||||
const everyDay: number[] = [];
|
||||
for (let d = minDay; d <= maxDay; d++) {
|
||||
everyDay.push(d);
|
||||
}
|
||||
|
||||
const result: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of byAnime) {
|
||||
const sorted = [...dayMap.entries()].sort(([a], [b]) => a - b);
|
||||
let cumulative = 0;
|
||||
for (const [epochDay, value] of sorted) {
|
||||
cumulative += value;
|
||||
result.push({ epochDay, animeTitle, value: cumulative });
|
||||
const firstDay = Math.min(...dayMap.keys());
|
||||
for (const day of everyDay) {
|
||||
if (day < firstDay) continue;
|
||||
cumulative += dayMap.get(day) ?? 0;
|
||||
result.push({ epochDay: day, animeTitle, value: cumulative });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -93,9 +107,12 @@ function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnime
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-ctp-subtext0 text-sm font-medium uppercase tracking-wider mt-6 mb-2 col-span-full">
|
||||
{children}
|
||||
</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +136,8 @@ export function TrendsTab() {
|
||||
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
|
||||
|
||||
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
|
||||
const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
|
||||
const wordsProgress = buildCumulativePerAnime(wordsPerAnime);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -141,13 +160,17 @@ export function TrendsTab() {
|
||||
type="line"
|
||||
/>
|
||||
|
||||
<SectionHeader>Anime</SectionHeader>
|
||||
<StackedTrendChart title="Anime Progress (episodes)" data={animeProgress} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
|
||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
|
||||
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
|
||||
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
|
||||
|
||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||
<StackedTrendChart title="Episodes Progress" data={animeProgress} />
|
||||
<StackedTrendChart title="Cards Mined Progress" data={cardsProgress} />
|
||||
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
|
||||
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />
|
||||
|
||||
Reference in New Issue
Block a user