mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 04:19:26 -07:00
feat(stats): add LibrarySummarySection with leaderboard chart and sortable table
This commit is contained in:
265
stats/src/components/trends/LibrarySummarySection.tsx
Normal file
265
stats/src/components/trends/LibrarySummarySection.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { LibrarySummaryRow } from '../../types/stats';
|
||||||
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||||
|
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
|
||||||
|
|
||||||
|
interface LibrarySummarySectionProps {
|
||||||
|
rows: LibrarySummaryRow[];
|
||||||
|
hiddenTitles: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEADERBOARD_LIMIT = 10;
|
||||||
|
const LEADERBOARD_HEIGHT = 260;
|
||||||
|
const LEADERBOARD_BAR_COLOR = '#8aadf4';
|
||||||
|
const TABLE_MAX_HEIGHT = 480;
|
||||||
|
|
||||||
|
type SortColumn =
|
||||||
|
| 'title'
|
||||||
|
| 'watchTimeMin'
|
||||||
|
| 'videos'
|
||||||
|
| 'sessions'
|
||||||
|
| 'cards'
|
||||||
|
| 'words'
|
||||||
|
| 'lookups'
|
||||||
|
| 'lookupsPerHundred'
|
||||||
|
| 'firstWatched';
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
interface ColumnDef {
|
||||||
|
id: SortColumn;
|
||||||
|
label: string;
|
||||||
|
align: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: ColumnDef[] = [
|
||||||
|
{ id: 'title', label: 'Title', align: 'left' },
|
||||||
|
{ id: 'watchTimeMin', label: 'Watch Time', align: 'right' },
|
||||||
|
{ id: 'videos', label: 'Videos', align: 'right' },
|
||||||
|
{ id: 'sessions', label: 'Sessions', align: 'right' },
|
||||||
|
{ id: 'cards', label: 'Cards', align: 'right' },
|
||||||
|
{ id: 'words', label: 'Words', align: 'right' },
|
||||||
|
{ id: 'lookups', label: 'Lookups', align: 'right' },
|
||||||
|
{ id: 'lookupsPerHundred', label: 'Lookups/100w', align: 'right' },
|
||||||
|
{ id: 'firstWatched', label: 'Date Range', align: 'right' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function truncateTitle(title: string, maxChars: number): string {
|
||||||
|
if (title.length <= maxChars) return title;
|
||||||
|
return `${title.slice(0, maxChars - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(firstEpochDay: number, lastEpochDay: number): string {
|
||||||
|
const fmt = (epochDay: number) =>
|
||||||
|
epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
if (firstEpochDay === lastEpochDay) return fmt(firstEpochDay);
|
||||||
|
return `${fmt(firstEpochDay)} → ${fmt(lastEpochDay)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWatchTime(min: number): string {
|
||||||
|
return formatDuration(min * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRows(
|
||||||
|
a: LibrarySummaryRow,
|
||||||
|
b: LibrarySummaryRow,
|
||||||
|
column: SortColumn,
|
||||||
|
direction: SortDirection,
|
||||||
|
): number {
|
||||||
|
const sign = direction === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
if (column === 'title') {
|
||||||
|
return a.title.localeCompare(b.title) * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === 'firstWatched') {
|
||||||
|
return (a.firstWatched - b.firstWatched) * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === 'lookupsPerHundred') {
|
||||||
|
const aVal = a.lookupsPerHundred;
|
||||||
|
const bVal = b.lookupsPerHundred;
|
||||||
|
if (aVal === null && bVal === null) return 0;
|
||||||
|
if (aVal === null) return 1;
|
||||||
|
if (bVal === null) return -1;
|
||||||
|
return (aVal - bVal) * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aVal = a[column] as number;
|
||||||
|
const bVal = b[column] as number;
|
||||||
|
return (aVal - bVal) * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) {
|
||||||
|
const [sortColumn, setSortColumn] = useState<SortColumn>('watchTimeMin');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||||
|
|
||||||
|
const visibleRows = useMemo(
|
||||||
|
() => rows.filter((row) => !hiddenTitles.has(row.title)),
|
||||||
|
[rows, hiddenTitles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedRows = useMemo(
|
||||||
|
() => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
|
||||||
|
[visibleRows, sortColumn, sortDirection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaderboard = useMemo(
|
||||||
|
() =>
|
||||||
|
[...visibleRows]
|
||||||
|
.sort((a, b) => b.watchTimeMin - a.watchTimeMin)
|
||||||
|
.slice(0, LEADERBOARD_LIMIT)
|
||||||
|
.map((row) => ({
|
||||||
|
title: row.title,
|
||||||
|
displayTitle: truncateTitle(row.title, 24),
|
||||||
|
watchTimeMin: row.watchTimeMin,
|
||||||
|
})),
|
||||||
|
[visibleRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleRows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||||
|
<div className="text-xs text-ctp-overlay2">
|
||||||
|
No library activity in the selected window.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderClick = (column: SortColumn) => {
|
||||||
|
if (column === sortColumn) {
|
||||||
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection(column === 'title' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||||
|
<h3 className="text-xs font-semibold text-ctp-text mb-2">
|
||||||
|
Top Titles by Watch Time (min)
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
|
||||||
|
<BarChart
|
||||||
|
data={leaderboard}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="displayTitle"
|
||||||
|
width={160}
|
||||||
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||||
|
formatter={(value: number) => [`${value} min`, 'Watch Time']}
|
||||||
|
labelFormatter={(_label, payload) => {
|
||||||
|
const datum = payload?.[0]?.payload as { title?: string } | undefined;
|
||||||
|
return datum?.title ?? '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||||
|
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
|
||||||
|
<div
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{ maxHeight: TABLE_MAX_HEIGHT }}
|
||||||
|
>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-ctp-surface0">
|
||||||
|
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
|
||||||
|
{COLUMNS.map((column) => {
|
||||||
|
const isActive = column.id === sortColumn;
|
||||||
|
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={column.id}
|
||||||
|
scope="col"
|
||||||
|
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
|
||||||
|
column.align === 'right' ? 'text-right' : 'text-left'
|
||||||
|
} ${isActive ? 'text-ctp-text' : ''}`}
|
||||||
|
onClick={() => handleHeaderClick(column.id)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
{indicator}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedRows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.title}
|
||||||
|
className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate"
|
||||||
|
title={row.title}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{formatWatchTime(row.watchTimeMin)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{formatNumber(row.videos)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{formatNumber(row.sessions)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{formatNumber(row.cards)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{formatNumber(row.words)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{formatNumber(row.lookups)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||||
|
{row.lookupsPerHundred === null
|
||||||
|
? '—'
|
||||||
|
: row.lookupsPerHundred.toFixed(1)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
|
||||||
|
{formatDateRange(row.firstWatched, row.lastWatched)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user