From cfb2396791c0a2fb1f05afe903993407b376721b Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 9 Apr 2026 01:07:25 -0700 Subject: [PATCH] feat(stats): collapsible series groups in library tab --- .../components/library/LibraryTab.test.tsx | 56 +++++++ stats/src/components/library/LibraryTab.tsx | 157 ++++++++++++------ 2 files changed, 166 insertions(+), 47 deletions(-) create mode 100644 stats/src/components/library/LibraryTab.test.tsx diff --git a/stats/src/components/library/LibraryTab.test.tsx b/stats/src/components/library/LibraryTab.test.tsx new file mode 100644 index 00000000..bd6d5089 --- /dev/null +++ b/stats/src/components/library/LibraryTab.test.tsx @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isLibraryGroupCollapsed, + toggleLibraryGroupCollapse, +} from './LibraryTab'; + +interface FakeGroup { + key: string; + items: { videoId: number }[]; +} + +const multiVideoGroup: FakeGroup = { + key: 'series-a', + items: [{ videoId: 1 }, { videoId: 2 }, { videoId: 3 }], +}; + +const singletonGroup: FakeGroup = { + key: 'video-b', + items: [{ videoId: 99 }], +}; + +test('isLibraryGroupCollapsed defaults to collapsed for multi-video groups', () => { + assert.equal(isLibraryGroupCollapsed(multiVideoGroup, new Map()), true); +}); + +test('isLibraryGroupCollapsed defaults to expanded for singleton groups', () => { + assert.equal(isLibraryGroupCollapsed(singletonGroup, new Map()), false); +}); + +test('isLibraryGroupCollapsed honors an explicit user override', () => { + const overrides = new Map([ + ['series-a', false], + ['video-b', true], + ]); + assert.equal(isLibraryGroupCollapsed(multiVideoGroup, overrides), false); + assert.equal(isLibraryGroupCollapsed(singletonGroup, overrides), true); +}); + +test('toggleLibraryGroupCollapse flips a multi-video group from collapsed to expanded', () => { + const next = toggleLibraryGroupCollapse(new Map(), multiVideoGroup); + assert.equal(next.get('series-a'), false); + assert.equal(isLibraryGroupCollapsed(multiVideoGroup, next), false); +}); + +test('toggleLibraryGroupCollapse flips a singleton group from expanded to collapsed', () => { + const next = toggleLibraryGroupCollapse(new Map(), singletonGroup); + assert.equal(next.get('video-b'), true); + assert.equal(isLibraryGroupCollapsed(singletonGroup, next), true); +}); + +test('toggleLibraryGroupCollapse toggles back when called twice', () => { + const once = toggleLibraryGroupCollapse(new Map(), multiVideoGroup); + const twice = toggleLibraryGroupCollapse(once, multiVideoGroup); + assert.equal(isLibraryGroupCollapsed(multiVideoGroup, twice), true); +}); diff --git a/stats/src/components/library/LibraryTab.tsx b/stats/src/components/library/LibraryTab.tsx index bc37605a..6c9727e9 100644 --- a/stats/src/components/library/LibraryTab.tsx +++ b/stats/src/components/library/LibraryTab.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useMediaLibrary } from '../../hooks/useMediaLibrary'; import { formatDuration, formatNumber } from '../../lib/formatters'; import { @@ -13,10 +13,47 @@ interface LibraryTabProps { onNavigateToSession: (sessionId: number) => void; } +interface CollapsibleGroup { + key: string; + items: { videoId: number }[]; +} + +/** + * Compute whether a library group should render collapsed. + * + * Default behavior: multi-video groups (series) start collapsed so the library + * is browsable; singletons stay expanded since collapsing them is just noise. + * Once the user clicks a group header we record an explicit override in the + * Map and respect it from then on. + */ +export function isLibraryGroupCollapsed( + group: CollapsibleGroup, + overrides: Map, +): boolean { + const override = overrides.get(group.key); + if (override !== undefined) return override; + return group.items.length > 1; +} + +/** + * Return a new override map with `group`'s collapsed state flipped. + */ +export function toggleLibraryGroupCollapse( + overrides: Map, + group: CollapsibleGroup, +): Map { + const next = new Map(overrides); + next.set(group.key, !isLibraryGroupCollapsed(group, overrides)); + return next; +} + export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { const { media, loading, error, refresh } = useMediaLibrary(); const [search, setSearch] = useState(''); const [selectedVideoId, setSelectedVideoId] = useState(null); + const [collapsedOverrides, setCollapsedOverrides] = useState>( + () => new Map(), + ); const filtered = useMemo(() => { if (!search.trim()) return media; @@ -35,6 +72,10 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]); const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]); + const toggleGroup = useCallback((group: CollapsibleGroup) => { + setCollapsedOverrides((prev) => toggleLibraryGroupCollapse(prev, group)); + }, []); + if (selectedVideoId !== null) { return ( No media found ) : (
- {grouped.map((group) => ( -
-
- -
-
- {group.channelUrl ? ( - - {group.title} - - ) : ( + {grouped.map((group) => { + const isSingleVideo = group.items.length === 1; + const isCollapsed = isLibraryGroupCollapsed(group, collapsedOverrides); + const bodyId = `library-group-body-${group.key}`; + return ( +
+ + {!isCollapsed && ( +
+
+ {group.items.map((item) => ( + setSelectedVideoId(item.videoId)} + /> + ))} +
-
-
-
-
- {group.items.map((item) => ( - setSelectedVideoId(item.videoId)} - /> - ))} -
-
-
- ))} + )} + + ); + })}
)}