mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -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