From 8d45102848625f3836627faa2036b88089a7996a Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 9 Apr 2026 22:21:32 -0700 Subject: [PATCH] feat(stats): add LibrarySummarySection with leaderboard chart and sortable table --- .../trends/LibrarySummarySection.tsx | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 stats/src/components/trends/LibrarySummarySection.tsx diff --git a/stats/src/components/trends/LibrarySummarySection.tsx b/stats/src/components/trends/LibrarySummarySection.tsx new file mode 100644 index 00000000..980ff240 --- /dev/null +++ b/stats/src/components/trends/LibrarySummarySection.tsx @@ -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; +} + +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('watchTimeMin'); + const [sortDirection, setSortDirection] = useState('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 ( +
+
+ No library activity in the selected window. +
+
+ ); + } + + const handleHeaderClick = (column: SortColumn) => { + if (column === sortColumn) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortColumn(column); + setSortDirection(column === 'title' ? 'asc' : 'desc'); + } + }; + + return ( + <> +
+

+ Top Titles by Watch Time (min) +

+ + + + + + [`${value} min`, 'Watch Time']} + labelFormatter={(_label, payload) => { + const datum = payload?.[0]?.payload as { title?: string } | undefined; + return datum?.title ?? ''; + }} + /> + + + +
+
+

Per-Title Summary

+
+ + + + {COLUMNS.map((column) => { + const isActive = column.id === sortColumn; + const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : ''; + return ( + + ); + })} + + + + {sortedRows.map((row) => ( + + + + + + + + + + + + ))} + +
handleHeaderClick(column.id)} + > + {column.label} + {indicator} +
+ {row.title} + + {formatWatchTime(row.watchTimeMin)} + + {formatNumber(row.videos)} + + {formatNumber(row.sessions)} + + {formatNumber(row.cards)} + + {formatNumber(row.words)} + + {formatNumber(row.lookups)} + + {row.lookupsPerHundred === null + ? '—' + : row.lookupsPerHundred.toFixed(1)} + + {formatDateRange(row.firstWatched, row.lastWatched)} +
+
+
+ + ); +}