From b1acbae58023018764ad59e62910e55d6e9e11fa Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 9 Apr 2026 01:55:37 -0700 Subject: [PATCH] refactor(stats): drop unused LibraryTab and useMediaLibrary The live "Library" tab renders AnimeTab via App.tsx; LibraryTab and its useMediaLibrary hook were never wired in. Remove them so the dead code doesn't mislead readers, and drop the collapsible-library-group item from the feedback-pass changelog since it targeted dead code. --- changes/stats-dashboard-feedback-pass.md | 2 +- .../components/library/LibraryTab.test.tsx | 56 ----- stats/src/components/library/LibraryTab.tsx | 191 ------------------ stats/src/hooks/useMediaLibrary.test.ts | 65 ------ stats/src/hooks/useMediaLibrary.ts | 68 ------- 5 files changed, 1 insertion(+), 381 deletions(-) delete mode 100644 stats/src/components/library/LibraryTab.test.tsx delete mode 100644 stats/src/components/library/LibraryTab.tsx delete mode 100644 stats/src/hooks/useMediaLibrary.test.ts delete mode 100644 stats/src/hooks/useMediaLibrary.ts diff --git a/changes/stats-dashboard-feedback-pass.md b/changes/stats-dashboard-feedback-pass.md index a5772d0c..acbad33e 100644 --- a/changes/stats-dashboard-feedback-pass.md +++ b/changes/stats-dashboard-feedback-pass.md @@ -1,10 +1,10 @@ type: changed area: stats -- Library groups now collapse multi-video series behind a per-group dropdown so the grid stays browsable. - Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group. - Trends add a 365-day range next to the existing 7d/30d/90d/all options. - Library detail view gets a delete-episode action that removes the video and all its sessions. - Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen. - Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity. - Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility. +- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard. diff --git a/stats/src/components/library/LibraryTab.test.tsx b/stats/src/components/library/LibraryTab.test.tsx deleted file mode 100644 index bd6d5089..00000000 --- a/stats/src/components/library/LibraryTab.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 6c9727e9..00000000 --- a/stats/src/components/library/LibraryTab.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useMediaLibrary } from '../../hooks/useMediaLibrary'; -import { formatDuration, formatNumber } from '../../lib/formatters'; -import { - groupMediaLibraryItems, - summarizeMediaLibraryGroups, -} from '../../lib/media-library-grouping'; -import { CoverImage } from './CoverImage'; -import { MediaCard } from './MediaCard'; -import { MediaDetailView } from './MediaDetailView'; - -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; - const q = search.toLowerCase(); - return media.filter((m) => { - const haystacks = [ - m.canonicalTitle, - m.videoTitle, - m.channelName, - m.uploaderId, - m.channelId, - ].filter(Boolean); - return haystacks.some((value) => value!.toLowerCase().includes(q)); - }); - }, [media, search]); - 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 ( - { - setSelectedVideoId(null); - refresh(); - }} - /> - ); - } - - if (loading) return
Loading...
; - if (error) return
Error: {error}
; - - return ( -
-
- setSearch(e.target.value)} - className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue" - /> -
- {grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video - {summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)} -
-
- - {filtered.length === 0 ? ( -
No media found
- ) : ( -
- {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)} - /> - ))} -
-
- )} -
- ); - })} -
- )} -
- ); -} diff --git a/stats/src/hooks/useMediaLibrary.test.ts b/stats/src/hooks/useMediaLibrary.test.ts deleted file mode 100644 index 8ae94e3a..00000000 --- a/stats/src/hooks/useMediaLibrary.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { MediaLibraryItem } from '../types/stats'; -import { shouldRefreshMediaLibraryRows } from './useMediaLibrary'; - -const baseItem: MediaLibraryItem = { - videoId: 1, - canonicalTitle: 'watch?v=abc123', - totalSessions: 1, - totalActiveMs: 60_000, - totalCards: 0, - totalTokensSeen: 10, - lastWatchedMs: 1_000, - hasCoverArt: 0, - youtubeVideoId: 'abc123', - videoUrl: 'https://www.youtube.com/watch?v=abc123', - videoTitle: null, - videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg', - channelId: null, - channelName: null, - channelUrl: null, - channelThumbnailUrl: null, - uploaderId: null, - uploaderUrl: null, - description: null, -}; - -test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => { - assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true); -}); - -test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => { - assert.equal( - shouldRefreshMediaLibraryRows([ - { - ...baseItem, - videoTitle: 'Video Name', - channelName: 'Creator Name', - channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88', - }, - ]), - false, - ); -}); - -test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => { - assert.equal( - shouldRefreshMediaLibraryRows([ - { - ...baseItem, - youtubeVideoId: null, - videoUrl: null, - }, - ]), - false, - ); -}); - -test('useMediaLibrary is a function export', async () => { - // Verify the hook is exported as a function. The `refresh` return value - // is exercised at the component level; reactive re-fetch via version bump - // cannot be tested without a full React test environment (no @testing-library). - const mod = await import('./useMediaLibrary'); - assert.equal(typeof mod.useMediaLibrary, 'function'); -}); diff --git a/stats/src/hooks/useMediaLibrary.ts b/stats/src/hooks/useMediaLibrary.ts deleted file mode 100644 index 889fab2c..00000000 --- a/stats/src/hooks/useMediaLibrary.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { getStatsClient } from './useStatsApi'; -import type { MediaLibraryItem } from '../types/stats'; - -const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500; -const MEDIA_LIBRARY_MAX_RETRIES = 3; - -export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean { - return rows.some((row) => { - if (!row.youtubeVideoId) { - return false; - } - return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim(); - }); -} - -export function useMediaLibrary() { - const [media, setMedia] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [version, setVersion] = useState(0); - - const refresh = useCallback(() => setVersion((v) => v + 1), []); - - useEffect(() => { - let cancelled = false; - let retryCount = 0; - let retryTimer: ReturnType | null = null; - - const load = (isInitial = false) => { - if (isInitial) { - setLoading(true); - setError(null); - } - getStatsClient() - .getMediaLibrary() - .then((rows) => { - if (cancelled) return; - setMedia(rows); - if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) { - retryCount += 1; - retryTimer = setTimeout(() => { - retryTimer = null; - load(false); - }, MEDIA_LIBRARY_REFRESH_DELAY_MS); - } - }) - .catch((err: Error) => { - if (cancelled) return; - setError(err.message); - }) - .finally(() => { - if (cancelled || !isInitial) return; - setLoading(false); - }); - }; - - load(true); - return () => { - cancelled = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [version]); - - return { media, loading, error, refresh }; -}