diff --git a/stats/src/App.tsx b/stats/src/App.tsx index e33fca3..22ab806 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -3,6 +3,7 @@ import { TabBar } from './components/layout/TabBar'; import { OverviewTab } from './components/overview/OverviewTab'; import { TrendsTab } from './components/trends/TrendsTab'; import { AnimeTab } from './components/anime/AnimeTab'; +import { LibraryTab } from './components/library/LibraryTab'; import { VocabularyTab } from './components/vocabulary/VocabularyTab'; import { SessionsTab } from './components/sessions/SessionsTab'; import { WordDetailPanel } from './components/vocabulary/WordDetailPanel'; @@ -11,23 +12,43 @@ import type { TabId } from './components/layout/TabBar'; export function App() { const [activeTab, setActiveTab] = useState('overview'); + const [mountedTabs, setMountedTabs] = useState>(() => new Set(['overview'])); const [selectedAnimeId, setSelectedAnimeId] = useState(null); + const [focusedSessionId, setFocusedSessionId] = useState(null); const [globalWordId, setGlobalWordId] = useState(null); const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords(); - const navigateToAnime = useCallback((animeId: number) => { - setActiveTab('anime'); - setSelectedAnimeId(animeId); + const activateTab = useCallback((tabId: TabId) => { + setActiveTab(tabId); + setMountedTabs((prev) => { + if (prev.has(tabId)) return prev; + const next = new Set(prev); + next.add(tabId); + return next; + }); }, []); + const navigateToAnime = useCallback((animeId: number) => { + activateTab('anime'); + setSelectedAnimeId(animeId); + }, [activateTab]); + + const navigateToSession = useCallback((sessionId: number) => { + activateTab('sessions'); + setFocusedSessionId(sessionId); + }, [activateTab]); + const openWordDetail = useCallback((wordId: number) => { setGlobalWordId(wordId); }, []); const handleTabChange = useCallback((tabId: TabId) => { - setActiveTab(tabId); + activateTab(tabId); setSelectedAnimeId(null); - }, []); + if (tabId !== 'sessions') { + setFocusedSessionId(null); + } + }, [activateTab]); return (
@@ -43,23 +64,23 @@ export function App() {
- {activeTab === 'overview' ? ( + {mountedTabs.has('overview') ? ( ) : null} - {activeTab === 'anime' ? ( + {mountedTabs.has('anime') ? ( ) : null} - {activeTab === 'trends' ? ( + {mountedTabs.has('trends') ? ( ) : null} - {activeTab === 'vocabulary' ? ( + {mountedTabs.has('vocabulary') ? ( ) : null} - {activeTab === 'sessions' ? ( + {mountedTabs.has('library') ? ( + + ) : null} + {mountedTabs.has('sessions') ? ( ) : null}
diff --git a/stats/src/components/layout/TabBar.tsx b/stats/src/components/layout/TabBar.tsx index 1815c6f..390eb52 100644 --- a/stats/src/components/layout/TabBar.tsx +++ b/stats/src/components/layout/TabBar.tsx @@ -1,4 +1,6 @@ -export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions'; +import { useRef, type KeyboardEvent } from 'react'; + +export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions' | 'library'; interface Tab { id: TabId; @@ -9,6 +11,7 @@ const TABS: Tab[] = [ { id: 'overview', label: 'Overview' }, { id: 'anime', label: 'Anime' }, { id: 'trends', label: 'Trends' }, + { id: 'library', label: 'Library' }, { id: 'vocabulary', label: 'Vocabulary' }, { id: 'sessions', label: 'Sessions' }, ]; @@ -19,18 +22,58 @@ interface TabBarProps { } export function TabBar({ activeTab, onTabChange }: TabBarProps) { + const tabRefs = useRef>([]); + + const activateAtIndex = (index: number) => { + const tab = TABS[index]; + if (!tab) return; + tabRefs.current[index]?.focus(); + onTabChange(tab.id); + }; + + const onTabKeyDown = (event: KeyboardEvent, index: number) => { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + activateAtIndex((index + 1) % TABS.length); + return; + } + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault(); + activateAtIndex((index - 1 + TABS.length) % TABS.length); + return; + } + if (event.key === 'Home') { + event.preventDefault(); + activateAtIndex(0); + return; + } + if (event.key === 'End') { + event.preventDefault(); + activateAtIndex(TABS.length - 1); + } + }; + return ( -
-

Tracking Snapshot

+

Tracking Snapshot

+

+ Today cards/episodes are daily values. Lifetime totals are sourced from summary tables. +

{showTrackedCardNote && (
- No tracked card-add events in the current immersion DB yet. New cards mined after this - fix will show here. + No lifetime card totals in the summary table yet. New cards mined after this fix will + appear here.
)}
-
Total Sessions
+
+ Lifetime Sessions +
{formatNumber(summary.totalSessions)}
@@ -55,33 +64,33 @@ export function OverviewTab() {
-
All-Time Hours
+
Lifetime Hours
{formatNumber(summary.allTimeHours)}
-
Active Days
+
Lifetime Days
{formatNumber(summary.activeDays)}
-
Cards Mined
+
Lifetime Cards
{formatNumber(summary.totalTrackedCards)}
- Episodes Completed + Lifetime Episodes
{formatNumber(summary.totalEpisodesWatched)}
-
Anime Completed
+
Lifetime Anime
{formatNumber(summary.totalAnimeCompleted)}
@@ -89,7 +98,7 @@ export function OverviewTab() {
- + ); } diff --git a/stats/src/components/overview/RecentSessions.tsx b/stats/src/components/overview/RecentSessions.tsx index 0048881..a6884fb 100644 --- a/stats/src/components/overview/RecentSessions.tsx +++ b/stats/src/components/overview/RecentSessions.tsx @@ -3,14 +3,14 @@ import { formatDuration, formatRelativeDate, formatNumber, - todayLocalDay, - localDayFromMs, + formatSessionDayLabel, } from '../../lib/formatters'; import { BASE_URL } from '../../lib/api-client'; import type { SessionSummary } from '../../types/stats'; interface RecentSessionsProps { sessions: SessionSummary[]; + onNavigateToSession: (sessionId: number) => void; } interface AnimeGroup { @@ -26,26 +26,14 @@ interface AnimeGroup { function groupSessionsByDay(sessions: SessionSummary[]): Map { const groups = new Map(); - const today = todayLocalDay(); for (const session of sessions) { - const sessionDay = localDayFromMs(session.startedAtMs); - let label: string; - if (sessionDay === today) { - label = 'Today'; - } else if (sessionDay === today - 1) { - label = 'Yesterday'; - } else { - label = new Date(session.startedAtMs).toLocaleDateString(undefined, { - month: 'long', - day: 'numeric', - }); - } - const group = groups.get(label); + const dayLabel = formatSessionDayLabel(session.startedAtMs); + const group = groups.get(dayLabel); if (group) { group.push(session); } else { - groups.set(label, [session]); + groups.set(dayLabel, [session]); } } @@ -86,10 +74,19 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] { return Array.from(map.values()); } -function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) { +function CoverThumbnail({ + animeId, + videoId, + title, +}: { + animeId: number | null; + videoId: number | null; + title: string; +}) { const fallbackChar = title.charAt(0) || '?'; + const [isFallback, setIsFallback] = useState(false); - if (!videoId) { + if ((!animeId && !videoId) || isFallback) { return (
{fallbackChar} @@ -97,28 +94,39 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str ); } + const src = + animeId != null + ? `${BASE_URL}/api/stats/anime/${animeId}/cover` + : `${BASE_URL}/api/stats/media/${videoId}/cover`; + return ( { - const target = e.currentTarget; - target.style.display = 'none'; - const placeholder = document.createElement('div'); - placeholder.className = - 'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0'; - placeholder.textContent = fallbackChar; - target.parentElement?.insertBefore(placeholder, target); - }} + onError={() => setIsFallback(true)} /> ); } -function SessionItem({ session }: { session: SessionSummary }) { +function SessionItem({ + session, + onNavigateToSession, +}: { + session: SessionSummary; + onNavigateToSession: (sessionId: number) => void; +}) { return ( -
- +
+ ); } -function AnimeGroupRow({ group }: { group: AnimeGroup }) { +function AnimeGroupRow({ + group, + onNavigateToSession, +}: { + group: AnimeGroup; + onNavigateToSession: (sessionId: number) => void; +}) { const [expanded, setExpanded] = useState(false); if (group.sessions.length === 1) { - return ; + return ( + + ); } const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media'; const mostRecentSession = group.sessions[0]!; + const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`; return (
{expanded && ( -
+
{group.sessions.map((s) => ( -
onNavigateToSession(s.sessionId)} + className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer" > - +
{s.canonicalTitle ?? 'Unknown Media'} @@ -220,7 +250,7 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
words
-
+ ))}
)} @@ -228,7 +258,7 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) { ); } -export function RecentSessions({ sessions }: RecentSessionsProps) { +export function RecentSessions({ sessions, onNavigateToSession }: RecentSessionsProps) { if (sessions.length === 0) { return (
@@ -253,7 +283,7 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
{animeGroups.map((group) => ( - + ))}
diff --git a/stats/src/components/sessions/SessionRow.tsx b/stats/src/components/sessions/SessionRow.tsx index 24790ef..5573a94 100644 --- a/stats/src/components/sessions/SessionRow.tsx +++ b/stats/src/components/sessions/SessionRow.tsx @@ -12,11 +12,19 @@ interface SessionRowProps { deleteDisabled?: boolean; } -function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) { +function CoverThumbnail({ + animeId, + videoId, + title, +}: { + animeId: number | null; + videoId: number | null; + title: string; +}) { const [failed, setFailed] = useState(false); const fallbackChar = title.charAt(0) || '?'; - if (!videoId || failed) { + if ((!animeId && !videoId) || failed) { return (
{fallbackChar} @@ -24,9 +32,14 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str ); } + const src = + animeId != null + ? `${BASE_URL}/api/stats/anime/${animeId}/cover` + : `${BASE_URL}/api/stats/media/${videoId}/cover`; + return ( + +
+
+
+ {animeTitles.map((title) => { + const isVisible = !hiddenAnime.has(title); + return ( + + ); + })} +
+
+ ); +} + export function TrendsTab() { const [range, setRange] = useState('30d'); const [groupBy, setGroupBy] = useState('day'); + const [hiddenAnime, setHiddenAnime] = useState>(() => new Set()); const { data, loading, error } = useTrends(range, groupBy); if (loading) return
Loading...
; @@ -140,6 +218,24 @@ export function TrendsTab() { const animeProgress = buildCumulativePerAnime(episodesPerAnime); const cardsProgress = buildCumulativePerAnime(cardsPerAnime); const wordsProgress = buildCumulativePerAnime(wordsPerAnime); + const animeTitles = buildAnimeVisibilityOptions([ + episodesPerAnime, + watchTimePerAnime, + cardsPerAnime, + wordsPerAnime, + animeProgress, + cardsProgress, + wordsProgress, + ]); + const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles); + + const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime); + const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime); + const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime); + const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime); + const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime); + const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime); + const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime); return (
@@ -168,15 +264,32 @@ export function TrendsTab() { /> Anime — Per Day - - - - + setHiddenAnime(new Set())} + onHideAll={() => setHiddenAnime(new Set(animeTitles))} + onToggleAnime={(title) => + setHiddenAnime((current) => { + const next = new Set(current); + if (next.has(title)) { + next.delete(title); + } else { + next.add(title); + } + return next; + }) + } + /> + + + + Anime — Cumulative - - - + + + Patterns { + const titles = buildAnimeVisibilityOptions([ + SAMPLE_POINTS, + [ + { epochDay: 1, animeTitle: 'Little Witch Academia', value: 8 }, + { epochDay: 1, animeTitle: 'KonoSuba', value: 1 }, + ], + ]); + + assert.deepEqual(titles, ['Trapped in a Dating Sim', 'KonoSuba', 'Little Witch Academia']); +}); + +test('filterHiddenAnimeData removes globally hidden anime from chart data', () => { + const filtered = filterHiddenAnimeData(SAMPLE_POINTS, new Set(['KonoSuba'])); + + assert.equal( + filtered.some((point) => point.animeTitle === 'KonoSuba'), + false, + ); + assert.equal(filtered.length, 2); +}); + +test('pruneHiddenAnime drops titles that are no longer available', () => { + const hidden = pruneHiddenAnime(new Set(['KonoSuba', 'Ghost in the Shell']), [ + 'KonoSuba', + 'Little Witch Academia', + ]); + + assert.deepEqual([...hidden], ['KonoSuba']); +}); diff --git a/stats/src/components/trends/anime-visibility.ts b/stats/src/components/trends/anime-visibility.ts new file mode 100644 index 0000000..42ac0f6 --- /dev/null +++ b/stats/src/components/trends/anime-visibility.ts @@ -0,0 +1,32 @@ +import type { PerAnimeDataPoint } from './StackedTrendChart'; + +export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] { + const totals = new Map(); + for (const dataset of datasets) { + for (const point of dataset) { + totals.set(point.animeTitle, (totals.get(point.animeTitle) ?? 0) + point.value); + } + } + + return [...totals.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([title]) => title); +} + +export function filterHiddenAnimeData( + data: PerAnimeDataPoint[], + hiddenAnime: ReadonlySet, +): PerAnimeDataPoint[] { + if (hiddenAnime.size === 0) { + return data; + } + return data.filter((point) => !hiddenAnime.has(point.animeTitle)); +} + +export function pruneHiddenAnime( + hiddenAnime: ReadonlySet, + availableAnime: readonly string[], +): Set { + const availableSet = new Set(availableAnime); + return new Set([...hiddenAnime].filter((title) => availableSet.has(title))); +} diff --git a/stats/src/components/vocabulary/KanjiDetailPanel.tsx b/stats/src/components/vocabulary/KanjiDetailPanel.tsx index 21ad6b6..5c8ddd7 100644 --- a/stats/src/components/vocabulary/KanjiDetailPanel.tsx +++ b/stats/src/components/vocabulary/KanjiDetailPanel.tsx @@ -1,7 +1,7 @@ -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { useKanjiDetail } from '../../hooks/useKanjiDetail'; import { apiClient } from '../../lib/api-client'; -import { formatNumber, formatRelativeDate } from '../../lib/formatters'; +import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters'; import type { VocabularyOccurrenceEntry } from '../../types/stats'; const OCCURRENCES_PAGE_SIZE = 50; @@ -36,6 +36,16 @@ export function KanjiDetailPanel({ const [occLoaded, setOccLoaded] = useState(false); const requestIdRef = useRef(0); + useEffect(() => { + setOccurrences([]); + setOccLoaded(false); + setOccLoading(false); + setOccLoadingMore(false); + setOccError(null); + setHasMore(false); + requestIdRef.current++; + }, [kanjiId]); + if (kanjiId === null) return null; const loadOccurrences = async (kanji: string, offset: number, append: boolean) => { @@ -123,13 +133,13 @@ export function KanjiDetailPanel({
- {formatRelativeDate(data.detail.firstSeen)} + {formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
First Seen
- {formatRelativeDate(data.detail.lastSeen)} + {formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
Last Seen
diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx index 09db98f..c8b3162 100644 --- a/stats/src/components/vocabulary/WordDetailPanel.tsx +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -1,7 +1,7 @@ import { useRef, useState, useEffect } from 'react'; import { useWordDetail } from '../../hooks/useWordDetail'; import { apiClient } from '../../lib/api-client'; -import { formatNumber, formatRelativeDate } from '../../lib/formatters'; +import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { fullReading } from '../../lib/reading-utils'; import type { VocabularyOccurrenceEntry } from '../../types/stats'; import { PosBadge } from './pos-helpers'; @@ -256,13 +256,13 @@ export function WordDetailPanel({
- {formatRelativeDate(data.detail.firstSeen)} + {formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
First Seen
- {formatRelativeDate(data.detail.lastSeen)} + {formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
Last Seen
diff --git a/stats/src/hooks/useAnimeDetail.ts b/stats/src/hooks/useAnimeDetail.ts index c1b3766..b679fda 100644 --- a/stats/src/hooks/useAnimeDetail.ts +++ b/stats/src/hooks/useAnimeDetail.ts @@ -9,14 +9,34 @@ export function useAnimeDetail(animeId: number | null) { const [reloadKey, setReloadKey] = useState(0); useEffect(() => { - if (animeId === null) return; + let cancelled = false; + if (animeId === null) { + setData(null); + setLoading(false); + setError(null); + return () => { + cancelled = true; + }; + } setLoading(true); setError(null); getStatsClient() .getAnimeDetail(animeId) - .then(setData) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); + .then((next) => { + if (cancelled) return; + setData(next); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, [animeId, reloadKey]); const reload = useCallback(() => setReloadKey((k) => k + 1), []); diff --git a/stats/src/hooks/useKanjiDetail.ts b/stats/src/hooks/useKanjiDetail.ts index f5be433..e929938 100644 --- a/stats/src/hooks/useKanjiDetail.ts +++ b/stats/src/hooks/useKanjiDetail.ts @@ -8,14 +8,34 @@ export function useKanjiDetail(kanjiId: number | null) { const [error, setError] = useState(null); useEffect(() => { - if (kanjiId === null) return; + let cancelled = false; + if (kanjiId === null) { + setData(null); + setLoading(false); + setError(null); + return () => { + cancelled = true; + }; + } setLoading(true); setError(null); getStatsClient() .getKanjiDetail(kanjiId) - .then(setData) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); + .then((next) => { + if (cancelled) return; + setData(next); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, [kanjiId]); return { data, loading, error }; diff --git a/stats/src/hooks/useMediaDetail.ts b/stats/src/hooks/useMediaDetail.ts index b8ef195..0ca4036 100644 --- a/stats/src/hooks/useMediaDetail.ts +++ b/stats/src/hooks/useMediaDetail.ts @@ -8,14 +8,34 @@ export function useMediaDetail(videoId: number | null) { const [error, setError] = useState(null); useEffect(() => { - if (videoId === null) return; + let cancelled = false; + if (videoId === null) { + setData(null); + setLoading(false); + setError(null); + return () => { + cancelled = true; + }; + } setLoading(true); setError(null); getStatsClient() .getMediaDetail(videoId) - .then(setData) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); + .then((next) => { + if (cancelled) return; + setData(next); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, [videoId]); return { data, loading, error }; diff --git a/stats/src/hooks/useMediaLibrary.ts b/stats/src/hooks/useMediaLibrary.ts index 9842a4b..685a2fb 100644 --- a/stats/src/hooks/useMediaLibrary.ts +++ b/stats/src/hooks/useMediaLibrary.ts @@ -8,11 +8,26 @@ export function useMediaLibrary() { const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); getStatsClient() .getMediaLibrary() - .then(setMedia) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); + .then((rows) => { + if (cancelled) return; + setMedia(rows); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, []); return { media, loading, error }; diff --git a/stats/src/hooks/useOverview.ts b/stats/src/hooks/useOverview.ts index 254d117..60c61cc 100644 --- a/stats/src/hooks/useOverview.ts +++ b/stats/src/hooks/useOverview.ts @@ -9,14 +9,27 @@ export function useOverview() { const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); const client = getStatsClient(); Promise.all([client.getOverview(), client.getSessions(50)]) .then(([overview, allSessions]) => { + if (cancelled) return; setData(overview); setSessions(allSessions); }) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)); + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, []); return { data, sessions, loading, error }; diff --git a/stats/src/hooks/useTrends.ts b/stats/src/hooks/useTrends.ts index 645cab5..6d39121 100644 --- a/stats/src/hooks/useTrends.ts +++ b/stats/src/hooks/useTrends.ts @@ -35,12 +35,19 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) { const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; setLoading(true); setError(null); const client = getStatsClient(); const limitMap: Record = { '7d': 7, '30d': 30, '90d': 90, all: 365 }; const limit = limitMap[range]; const monthlyLimit = Math.max(1, Math.ceil(limit / 30)); + const sessionsLimitMap: Record = { + '7d': 200, + '30d': 500, + '90d': 500, + all: 500, + }; const rollupFetcher = groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit); @@ -50,23 +57,43 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) { client.getEpisodesPerDay(limit), client.getNewAnimePerDay(limit), client.getWatchTimePerAnime(limit), - client.getSessions(500), + client.getSessions(sessionsLimitMap[range]), client.getAnimeLibrary(), ]) .then( ([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => { + if (cancelled) return; + const now = new Date(); + const localMidnight = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const cutoffMs = + range === 'all' ? null : localMidnight - (limitMap[range] - 1) * 86_400_000; + const filteredSessions = + cutoffMs == null ? sessions : sessions.filter((s) => s.startedAtMs >= cutoffMs); setData({ rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, - sessions, + sessions: filteredSessions, animeLibrary, }); }, ) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)); + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, [range, groupBy]); return { data, loading, error }; diff --git a/stats/src/hooks/useVocabulary.ts b/stats/src/hooks/useVocabulary.ts index 9c8fd0e..7ff455b 100644 --- a/stats/src/hooks/useVocabulary.ts +++ b/stats/src/hooks/useVocabulary.ts @@ -10,11 +10,13 @@ export function useVocabulary() { const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; setLoading(true); setError(null); const client = getStatsClient(); Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()]) .then(([wordsResult, kanjiResult, knownResult]) => { + if (cancelled) return; const errors: string[] = []; if (wordsResult.status === 'fulfilled') { @@ -37,7 +39,13 @@ export function useVocabulary() { setError(errors.join('; ')); } }) - .finally(() => setLoading(false)); + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, []); return { words, kanji, knownWords, loading, error }; diff --git a/stats/src/hooks/useWordDetail.ts b/stats/src/hooks/useWordDetail.ts index d98ddf7..b22c7bb 100644 --- a/stats/src/hooks/useWordDetail.ts +++ b/stats/src/hooks/useWordDetail.ts @@ -8,14 +8,34 @@ export function useWordDetail(wordId: number | null) { const [error, setError] = useState(null); useEffect(() => { - if (wordId === null) return; + let cancelled = false; + if (wordId === null) { + setData(null); + setLoading(false); + setError(null); + return () => { + cancelled = true; + }; + } setLoading(true); setError(null); getStatsClient() .getWordDetail(wordId) - .then(setData) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); + .then((next) => { + if (cancelled) return; + setData(next); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; }, [wordId]); return { data, loading, error }; diff --git a/stats/src/lib/dashboard-data.test.ts b/stats/src/lib/dashboard-data.test.ts index d19d51a..f5dabfc 100644 --- a/stats/src/lib/dashboard-data.test.ts +++ b/stats/src/lib/dashboard-data.test.ts @@ -52,25 +52,88 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () => lookupHitRate: 0.8, }, ]; + const overview: OverviewData = { + sessions, + rollups, + hints: { + totalSessions: 1, + activeSessions: 0, + episodesToday: 2, + activeAnimeCount: 3, + totalEpisodesWatched: 5, + totalAnimeCompleted: 1, + totalActiveMin: 50, + activeDays: 2, + totalCards: 9, + }, + }; + + const summary = buildOverviewSummary(overview, now); + assert.equal(summary.todayCards, 2); + assert.equal(summary.totalTrackedCards, 9); + assert.equal(summary.episodesToday, 2); + assert.equal(summary.activeAnimeCount, 3); + assert.equal(summary.averageSessionMinutes, 50); + assert.equal(summary.allTimeHours, 1); + assert.equal(summary.activeDays, 2); +}); + +test('buildOverviewSummary prefers lifetime totals from hints when provided', () => { + const now = Date.UTC(2026, 2, 13, 12); + const today = Math.floor(now / 86_400_000); const overview: OverviewData = { - sessions, - rollups, + sessions: [ + { + sessionId: 2, + canonicalTitle: 'B', + videoId: 2, + animeId: null, + animeTitle: null, + startedAtMs: now - 60_000, + endedAtMs: now, + totalWatchedMs: 60_000, + activeWatchedMs: 60_000, + linesSeen: 10, + wordsSeen: 10, + tokensSeen: 10, + cardsMined: 10, + lookupCount: 1, + lookupHits: 1, + }, + ], + rollups: [ + { + rollupDayOrMonth: today, + videoId: 2, + totalSessions: 1, + totalActiveMin: 1, + totalLinesSeen: 10, + totalWordsSeen: 10, + totalTokensSeen: 10, + totalCards: 10, + cardsPerHour: 600, + wordsPerMin: 10, + lookupHitRate: 1, + }, + ], hints: { - totalSessions: 1, + totalSessions: 999, activeSessions: 0, - episodesToday: 2, - activeAnimeCount: 3, - totalEpisodesWatched: 5, - totalAnimeCompleted: 1, + episodesToday: 0, + activeAnimeCount: 0, + totalEpisodesWatched: 0, + totalAnimeCompleted: 0, + totalActiveMin: 120, + activeDays: 40, + totalCards: 5, }, }; const summary = buildOverviewSummary(overview, now); - assert.equal(summary.todayCards, 2); - assert.equal(summary.totalTrackedCards, 2); - assert.equal(summary.episodesToday, 2); - assert.equal(summary.activeAnimeCount, 3); - assert.equal(summary.averageSessionMinutes, 50); + assert.equal(summary.totalTrackedCards, 5); + assert.equal(summary.totalSessions, 999); + assert.equal(summary.allTimeHours, 2); + assert.equal(summary.activeDays, 40); }); test('buildVocabularySummary treats firstSeen timestamps as seconds', () => { diff --git a/stats/src/lib/dashboard-data.ts b/stats/src/lib/dashboard-data.ts index 9ee6006..0a24181 100644 --- a/stats/src/lib/dashboard-data.ts +++ b/stats/src/lib/dashboard-data.ts @@ -5,7 +5,7 @@ import type { StreakCalendarDay, VocabularyEntry, } from '../types/stats'; -import { epochDayToDate, localDayFromMs } from './formatters'; +import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters'; export interface ChartPoint { label: string; @@ -47,6 +47,10 @@ export interface VocabularySummary { recentDiscoveries: VocabularyEntry[]; } +function normalizeDbTimestampSeconds(ts: number): number { + return Math.floor(epochMsFromDbTimestamp(ts) / 1000); +} + function makeRollupLabel(value: number): string { if (value > 100_000) { const year = Math.floor(value / 100); @@ -135,6 +139,8 @@ export function buildOverviewSummary( const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined); const rollupCards = sumBy(aggregated, (row) => row.cards); + const lifetimeCards = overview.hints.totalCards ?? Math.max(sessionCards, rollupCards); + const totalActiveMin = overview.hints.totalActiveMin ?? sumBy(aggregated, (row) => row.activeMin); let streakDays = 0; const streakStart = daysWithActivity.has(today) ? today : today - 1; @@ -155,8 +161,8 @@ export function buildOverviewSummary( sumBy(todaySessions, (session) => session.cardsMined), ), streakDays, - allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60), - totalTrackedCards: Math.max(sessionCards, rollupCards), + allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)), + totalTrackedCards: lifetimeCards, episodesToday: overview.hints.episodesToday ?? 0, activeAnimeCount: overview.hints.activeAnimeCount ?? 0, totalEpisodesWatched: overview.hints.totalEpisodesWatched ?? 0, @@ -170,7 +176,7 @@ export function buildOverviewSummary( ) : 0, totalSessions: overview.hints.totalSessions, - activeDays: daysWithActivity.size, + activeDays: overview.hints.activeDays ?? daysWithActivity.size, recentWatchTime: aggregated .slice(-14) .map((row) => ({ label: row.label, value: row.activeMin })), @@ -202,14 +208,18 @@ export function buildVocabularySummary( const byDay = new Map(); for (const word of words) { - const day = Math.floor(word.firstSeen / 86_400); + const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen); + const day = Math.floor(firstSeenSec / 86_400); byDay.set(day, (byDay.get(day) ?? 0) + 1); } return { uniqueWords: words.length, uniqueKanji: kanji.length, - newThisWeek: words.filter((word) => word.firstSeen >= weekAgoSec).length, + newThisWeek: words.filter((word) => { + const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen); + return firstSeenSec >= weekAgoSec; + }).length, topWords: [...words] .sort((left, right) => right.frequency - left.frequency) .slice(0, 12) @@ -222,7 +232,11 @@ export function buildVocabularySummary( value: count, })), recentDiscoveries: [...words] - .sort((left, right) => right.firstSeen - left.firstSeen) + .sort((left, right) => { + const leftFirst = normalizeDbTimestampSeconds(left.firstSeen); + const rightFirst = normalizeDbTimestampSeconds(right.firstSeen); + return rightFirst - leftFirst; + }) .slice(0, 8), }; } diff --git a/stats/src/lib/formatters.test.ts b/stats/src/lib/formatters.test.ts index 7be19df..f775917 100644 --- a/stats/src/lib/formatters.test.ts +++ b/stats/src/lib/formatters.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { formatRelativeDate } from './formatters'; +import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters'; test('formatRelativeDate: future timestamps return "just now"', () => { assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now'); @@ -27,12 +27,28 @@ test('formatRelativeDate: 2 hours ago returns "2h ago"', () => { assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago'); }); -test('formatRelativeDate: 23 hours ago returns "23h ago"', () => { - assert.equal(formatRelativeDate(Date.now() - 23 * 3_600_000), '23h ago'); +test('formatRelativeDate: same calendar day can return "23h ago"', () => { + const realNow = Date.now; + const now = new Date(2026, 2, 16, 23, 30, 0).getTime(); + const sameDayMorning = new Date(2026, 2, 16, 0, 30, 0).getTime(); + Date.now = () => now; + try { + assert.equal(formatRelativeDate(sameDayMorning), '23h ago'); + } finally { + Date.now = realNow; + } }); -test('formatRelativeDate: 36 hours ago returns "Yesterday"', () => { - assert.equal(formatRelativeDate(Date.now() - 36 * 3_600_000), 'Yesterday'); +test('formatRelativeDate: two calendar days ago returns "2d ago"', () => { + const realNow = Date.now; + const now = new Date(2026, 2, 16, 12, 0, 0).getTime(); + const twoDaysAgo = new Date(2026, 2, 14, 0, 0, 0).getTime(); + Date.now = () => now; + try { + assert.equal(formatRelativeDate(twoDaysAgo), '2d ago'); + } finally { + Date.now = realNow; + } }); test('formatRelativeDate: 5 days ago returns "5d ago"', () => { @@ -43,3 +59,43 @@ test('formatRelativeDate: 10 days ago returns locale date string', () => { const ts = Date.now() - 10 * 86_400_000; assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString()); }); + +test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => { + const realNow = Date.now; + const now = new Date(2026, 2, 16, 0, 30, 0).getTime(); + const previousDayLate = new Date(2026, 2, 15, 23, 45, 0).getTime(); + Date.now = () => now; + try { + assert.equal(formatRelativeDate(previousDayLate), 'Yesterday'); + } finally { + Date.now = realNow; + } +}); + +test('epochMsFromDbTimestamp converts seconds to ms', () => { + assert.equal(epochMsFromDbTimestamp(1_700_000_000), 1_700_000_000_000); +}); + +test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => { + assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000); +}); + +test('formatSessionDayLabel formats today and yesterday', () => { + const now = Date.now(); + const oneDayMs = 24 * 60 * 60_000; + assert.equal(formatSessionDayLabel(now), 'Today'); + assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday'); +}); + +test('formatSessionDayLabel includes year for past-year dates', () => { + const now = new Date(); + const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime(); + const label = formatSessionDayLabel(sameDayLastYear); + const year = new Date(sameDayLastYear).getFullYear(); + assert.ok(label.includes(String(year))); + const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, { + month: 'long', + day: 'numeric', + }); + assert.notEqual(label, withoutYear); +}); diff --git a/stats/src/lib/formatters.ts b/stats/src/lib/formatters.ts index bb4249c..d6b6b5e 100644 --- a/stats/src/lib/formatters.ts +++ b/stats/src/lib/formatters.ts @@ -18,14 +18,22 @@ export function formatPercent(ratio: number | null): string { export function formatRelativeDate(ms: number): string { const now = Date.now(); const diffMs = now - ms; - if (diffMs < 60_000) return 'just now'; - const diffMin = Math.floor(diffMs / 60_000); - if (diffMin < 60) return `${diffMin}m ago`; - const diffHours = Math.floor(diffMs / 3_600_000); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffMs / 86_400_000); - if (diffDays < 2) return 'Yesterday'; - if (diffDays < 7) return `${diffDays}d ago`; + if (diffMs <= 0) return 'just now'; + + const nowDay = localDayFromMs(now); + const sessionDay = localDayFromMs(ms); + const dayDiff = nowDay - sessionDay; + + if (dayDiff <= 0) { + if (diffMs < 60_000) return 'just now'; + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHours = Math.floor(diffMs / 3_600_000); + return `${diffHours}h ago`; + } + + if (dayDiff === 1) return 'Yesterday'; + if (dayDiff < 7) return `${dayDiff}d ago`; return new Date(ms).toLocaleDateString(); } @@ -42,3 +50,26 @@ export function localDayFromMs(ms: number): number { export function todayLocalDay(): number { return localDayFromMs(Date.now()); } + +// Immersion tracker stores word/kanji first_seen/last_seen as epoch seconds. +// Older fixtures or callers may still pass ms, so normalize defensively. +export function epochMsFromDbTimestamp(ts: number): number { + if (!Number.isFinite(ts)) return 0; + return ts < 10_000_000_000 ? Math.round(ts * 1000) : Math.round(ts); +} + +export function formatSessionDayLabel(sessionStartedAtMs: number): string { + const today = todayLocalDay(); + const day = localDayFromMs(sessionStartedAtMs); + + if (day === today) return 'Today'; + if (day === today - 1) return 'Yesterday'; + + const date = new Date(sessionStartedAtMs); + const includeYear = date.getFullYear() !== new Date().getFullYear(); + return date.toLocaleDateString(undefined, { + month: 'long', + day: 'numeric', + ...(includeYear ? { year: 'numeric' } : {}), + }); +} diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts index 7617bd6..a4333c6 100644 --- a/stats/src/types/stats.ts +++ b/stats/src/types/stats.ts @@ -97,6 +97,9 @@ export interface OverviewData { activeAnimeCount: number; totalEpisodesWatched: number; totalAnimeCompleted: number; + totalActiveMin: number; + activeDays: number; + totalCards?: number; }; }