feat: optimize stats dashboard data and components

This commit is contained in:
2026-03-17 00:48:56 -07:00
parent 11710f20db
commit 390ae1b2f2
24 changed files with 837 additions and 174 deletions

View File

@@ -3,6 +3,7 @@ import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab'; import { OverviewTab } from './components/overview/OverviewTab';
import { TrendsTab } from './components/trends/TrendsTab'; import { TrendsTab } from './components/trends/TrendsTab';
import { AnimeTab } from './components/anime/AnimeTab'; import { AnimeTab } from './components/anime/AnimeTab';
import { LibraryTab } from './components/library/LibraryTab';
import { VocabularyTab } from './components/vocabulary/VocabularyTab'; import { VocabularyTab } from './components/vocabulary/VocabularyTab';
import { SessionsTab } from './components/sessions/SessionsTab'; import { SessionsTab } from './components/sessions/SessionsTab';
import { WordDetailPanel } from './components/vocabulary/WordDetailPanel'; import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
@@ -11,23 +12,43 @@ import type { TabId } from './components/layout/TabBar';
export function App() { export function App() {
const [activeTab, setActiveTab] = useState<TabId>('overview'); const [activeTab, setActiveTab] = useState<TabId>('overview');
const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview']));
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null); const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
const [focusedSessionId, setFocusedSessionId] = useState<number | null>(null);
const [globalWordId, setGlobalWordId] = useState<number | null>(null); const [globalWordId, setGlobalWordId] = useState<number | null>(null);
const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords(); const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords();
const navigateToAnime = useCallback((animeId: number) => { const activateTab = useCallback((tabId: TabId) => {
setActiveTab('anime'); setActiveTab(tabId);
setSelectedAnimeId(animeId); 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) => { const openWordDetail = useCallback((wordId: number) => {
setGlobalWordId(wordId); setGlobalWordId(wordId);
}, []); }, []);
const handleTabChange = useCallback((tabId: TabId) => { const handleTabChange = useCallback((tabId: TabId) => {
setActiveTab(tabId); activateTab(tabId);
setSelectedAnimeId(null); setSelectedAnimeId(null);
}, []); if (tabId !== 'sessions') {
setFocusedSessionId(null);
}
}, [activateTab]);
return ( return (
<div className="min-h-screen flex flex-col bg-ctp-base"> <div className="min-h-screen flex flex-col bg-ctp-base">
@@ -43,23 +64,23 @@ export function App() {
<TabBar activeTab={activeTab} onTabChange={handleTabChange} /> <TabBar activeTab={activeTab} onTabChange={handleTabChange} />
</header> </header>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">
{activeTab === 'overview' ? ( {mountedTabs.has('overview') ? (
<section <section
id="panel-overview" id="panel-overview"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-overview" aria-labelledby="tab-overview"
key="overview" hidden={activeTab !== 'overview'}
className="animate-fade-in" className="animate-fade-in"
> >
<OverviewTab /> <OverviewTab onNavigateToSession={navigateToSession} />
</section> </section>
) : null} ) : null}
{activeTab === 'anime' ? ( {mountedTabs.has('anime') ? (
<section <section
id="panel-anime" id="panel-anime"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-anime" aria-labelledby="tab-anime"
key="anime" hidden={activeTab !== 'anime'}
className="animate-fade-in" className="animate-fade-in"
> >
<AnimeTab <AnimeTab
@@ -69,23 +90,23 @@ export function App() {
/> />
</section> </section>
) : null} ) : null}
{activeTab === 'trends' ? ( {mountedTabs.has('trends') ? (
<section <section
id="panel-trends" id="panel-trends"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-trends" aria-labelledby="tab-trends"
key="trends" hidden={activeTab !== 'trends'}
className="animate-fade-in" className="animate-fade-in"
> >
<TrendsTab /> <TrendsTab />
</section> </section>
) : null} ) : null}
{activeTab === 'vocabulary' ? ( {mountedTabs.has('vocabulary') ? (
<section <section
id="panel-vocabulary" id="panel-vocabulary"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-vocabulary" aria-labelledby="tab-vocabulary"
key="vocabulary" hidden={activeTab !== 'vocabulary'}
className="animate-fade-in" className="animate-fade-in"
> >
<VocabularyTab <VocabularyTab
@@ -98,15 +119,29 @@ export function App() {
/> />
</section> </section>
) : null} ) : null}
{activeTab === 'sessions' ? ( {mountedTabs.has('library') ? (
<section
id="panel-library"
role="tabpanel"
aria-labelledby="tab-library"
hidden={activeTab !== 'library'}
className="animate-fade-in"
>
<LibraryTab />
</section>
) : null}
{mountedTabs.has('sessions') ? (
<section <section
id="panel-sessions" id="panel-sessions"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-sessions" aria-labelledby="tab-sessions"
key="sessions" hidden={activeTab !== 'sessions'}
className="animate-fade-in" className="animate-fade-in"
> >
<SessionsTab /> <SessionsTab
initialSessionId={focusedSessionId}
onClearInitialSession={() => setFocusedSessionId(null)}
/>
</section> </section>
) : null} ) : null}
</main> </main>

View File

@@ -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 { interface Tab {
id: TabId; id: TabId;
@@ -9,6 +11,7 @@ const TABS: Tab[] = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'anime', label: 'Anime' }, { id: 'anime', label: 'Anime' },
{ id: 'trends', label: 'Trends' }, { id: 'trends', label: 'Trends' },
{ id: 'library', label: 'Library' },
{ id: 'vocabulary', label: 'Vocabulary' }, { id: 'vocabulary', label: 'Vocabulary' },
{ id: 'sessions', label: 'Sessions' }, { id: 'sessions', label: 'Sessions' },
]; ];
@@ -19,18 +22,58 @@ interface TabBarProps {
} }
export function TabBar({ activeTab, onTabChange }: TabBarProps) { export function TabBar({ activeTab, onTabChange }: TabBarProps) {
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const activateAtIndex = (index: number) => {
const tab = TABS[index];
if (!tab) return;
tabRefs.current[index]?.focus();
onTabChange(tab.id);
};
const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, 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 ( return (
<nav className="flex border-b border-ctp-surface1" role="tablist" aria-label="Stats tabs"> <nav
{TABS.map((tab) => ( className="flex border-b border-ctp-surface1"
role="tablist"
aria-label="Stats tabs"
aria-orientation="horizontal"
>
{TABS.map((tab, index) => (
<button <button
key={tab.id} key={tab.id}
id={`tab-${tab.id}`} id={`tab-${tab.id}`}
ref={(element) => {
tabRefs.current[index] = element;
}}
type="button" type="button"
role="tab" role="tab"
aria-controls={`panel-${tab.id}`} aria-controls={`panel-${tab.id}`}
aria-selected={activeTab === tab.id} aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1} tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
onKeyDown={(event) => onTabKeyDown(event, index)}
className={`px-4 py-2.5 text-sm font-medium transition-colors className={`px-4 py-2.5 text-sm font-medium transition-colors
${ ${
activeTab === tab.id activeTab === tab.id

View File

@@ -7,7 +7,11 @@ import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data'; import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters'; import { formatNumber } from '../../lib/formatters';
export function OverviewTab() { interface OverviewTabProps {
onNavigateToSession: (sessionId: number) => void;
}
export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
const { data, sessions, loading, error } = useOverview(); const { data, sessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90); const { calendar, loading: calLoading } = useStreakCalendar(90);
@@ -34,16 +38,21 @@ export function OverviewTab() {
</div> </div>
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3> <h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
Today cards/episodes are daily values. Lifetime totals are sourced from summary tables.
</p>
{showTrackedCardNote && ( {showTrackedCardNote && (
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0"> <div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
No tracked card-add events in the current immersion DB yet. New cards mined after this No lifetime card totals in the summary table yet. New cards mined after this fix will
fix will show here. appear here.
</div> </div>
)} )}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm"> <div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Total Sessions</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Lifetime Sessions
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
{formatNumber(summary.totalSessions)} {formatNumber(summary.totalSessions)}
</div> </div>
@@ -55,33 +64,33 @@ export function OverviewTab() {
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">All-Time Hours</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Hours</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
{formatNumber(summary.allTimeHours)} {formatNumber(summary.allTimeHours)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Days</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)} {formatNumber(summary.activeDays)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Cards</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)} {formatNumber(summary.totalTrackedCards)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Episodes Completed Lifetime Episodes
</div> </div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)} {formatNumber(summary.totalEpisodesWatched)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime Completed</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Anime</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)} {formatNumber(summary.totalAnimeCompleted)}
</div> </div>
@@ -89,7 +98,7 @@ export function OverviewTab() {
</div> </div>
</div> </div>
<RecentSessions sessions={sessions} /> <RecentSessions sessions={sessions} onNavigateToSession={onNavigateToSession} />
</div> </div>
); );
} }

View File

@@ -3,14 +3,14 @@ import {
formatDuration, formatDuration,
formatRelativeDate, formatRelativeDate,
formatNumber, formatNumber,
todayLocalDay, formatSessionDayLabel,
localDayFromMs,
} from '../../lib/formatters'; } from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client'; import { BASE_URL } from '../../lib/api-client';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
interface RecentSessionsProps { interface RecentSessionsProps {
sessions: SessionSummary[]; sessions: SessionSummary[];
onNavigateToSession: (sessionId: number) => void;
} }
interface AnimeGroup { interface AnimeGroup {
@@ -26,26 +26,14 @@ interface AnimeGroup {
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> { function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>(); const groups = new Map<string, SessionSummary[]>();
const today = todayLocalDay();
for (const session of sessions) { for (const session of sessions) {
const sessionDay = localDayFromMs(session.startedAtMs); const dayLabel = formatSessionDayLabel(session.startedAtMs);
let label: string; const group = groups.get(dayLabel);
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);
if (group) { if (group) {
group.push(session); group.push(session);
} else { } else {
groups.set(label, [session]); groups.set(dayLabel, [session]);
} }
} }
@@ -86,10 +74,19 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
return Array.from(map.values()); 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 fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
if (!videoId) { if ((!animeId && !videoId) || isFallback) {
return ( return (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0"> <div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar} {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 ( return (
<img <img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`} src={src}
alt="" alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2" className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={(e) => { onError={() => setIsFallback(true)}
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);
}}
/> />
); );
} }
function SessionItem({ session }: { session: SessionSummary }) { function SessionItem({
session,
onNavigateToSession,
}: {
session: SessionSummary;
onNavigateToSession: (sessionId: number) => void;
}) {
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3"> <button
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} /> type="button"
onClick={() => onNavigateToSession(session.sessionId)}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate"> <div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'} {session.canonicalTitle ?? 'Unknown Media'}
@@ -142,28 +150,43 @@ function SessionItem({ session }: { session: SessionSummary }) {
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
</div> </button>
); );
} }
function AnimeGroupRow({ group }: { group: AnimeGroup }) { function AnimeGroupRow({
group,
onNavigateToSession,
}: {
group: AnimeGroup;
onNavigateToSession: (sessionId: number) => void;
}) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
if (group.sessions.length === 1) { if (group.sessions.length === 1) {
return <SessionItem session={group.sessions[0]!} />; return (
<SessionItem session={group.sessions[0]!} onNavigateToSession={onNavigateToSession} />
);
} }
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media'; const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
const mostRecentSession = group.sessions[0]!; const mostRecentSession = group.sessions[0]!;
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return ( return (
<div> <div>
<button <button
type="button" type="button"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={disclosureId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left" className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
> >
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} /> <CoverThumbnail
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div> <div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2"> <div className="text-xs text-ctp-overlay2">
@@ -186,18 +209,25 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</div> </div>
<div <div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`} className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
> >
{'\u25B8'} {'\u25B8'}
</div> </div>
</button> </button>
{expanded && ( {expanded && (
<div className="ml-6 mt-1 space-y-1"> <div id={disclosureId} role="region" aria-label={`${displayTitle} sessions`} className="ml-6 mt-1 space-y-1">
{group.sessions.map((s) => ( {group.sessions.map((s) => (
<div <button
type="button"
key={s.sessionId} key={s.sessionId}
className="bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 flex items-center gap-3" onClick={() => 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"
> >
<CoverThumbnail videoId={s.videoId} title={s.canonicalTitle ?? 'Unknown'} /> <CoverThumbnail
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate"> <div className="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'} {s.canonicalTitle ?? 'Unknown Media'}
@@ -220,7 +250,7 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
</div> </button>
))} ))}
</div> </div>
)} )}
@@ -228,7 +258,7 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
); );
} }
export function RecentSessions({ sessions }: RecentSessionsProps) { export function RecentSessions({ sessions, onNavigateToSession }: RecentSessionsProps) {
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -253,7 +283,7 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{animeGroups.map((group) => ( {animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} /> <AnimeGroupRow key={group.key} group={group} onNavigateToSession={onNavigateToSession} />
))} ))}
</div> </div>
</div> </div>

View File

@@ -12,11 +12,19 @@ interface SessionRowProps {
deleteDisabled?: boolean; 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 [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?'; const fallbackChar = title.charAt(0) || '?';
if (!videoId || failed) { if ((!animeId && !videoId) || failed) {
return ( return (
<div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm font-bold shrink-0"> <div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm font-bold shrink-0">
{fallbackChar} {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 ( return (
<img <img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`} src={src}
alt="" alt=""
loading="lazy" loading="lazy"
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2" className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
@@ -52,7 +65,11 @@ export function SessionRow({
aria-controls={detailsId} aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left" className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
> >
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} /> <CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate"> <div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'} {session.canonicalTitle ?? 'Unknown Media'}

View File

@@ -4,38 +4,31 @@ import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail'; import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm'; import { confirmSessionDelete } from '../../lib/delete-confirm';
import { todayLocalDay, localDayFromMs } from '../../lib/formatters'; import { formatSessionDayLabel } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> { function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>(); const groups = new Map<string, SessionSummary[]>();
const today = todayLocalDay();
for (const session of sessions) { for (const session of sessions) {
const sessionDay = localDayFromMs(session.startedAtMs); const dayLabel = formatSessionDayLabel(session.startedAtMs);
let label: string; const group = groups.get(dayLabel);
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);
if (group) { if (group) {
group.push(session); group.push(session);
} else { } else {
groups.set(label, [session]); groups.set(dayLabel, [session]);
} }
} }
return groups; return groups;
} }
export function SessionsTab() { interface SessionsTabProps {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
}
export function SessionsTab({ initialSessionId, onClearInitialSession }: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions(); const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -47,6 +40,29 @@ export function SessionsTab() {
setVisibleSessions(sessions); setVisibleSessions(sessions);
}, [sessions]); }, [sessions]);
useEffect(() => {
if (initialSessionId != null && sessions.length > 0) {
let canceled = false;
setExpandedId(initialSessionId);
onClearInitialSession?.();
const frame = requestAnimationFrame(() => {
if (canceled) return;
const el = document.getElementById(`session-details-${initialSessionId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Session row itself if detail hasn't rendered yet
const row = document.querySelector(`[aria-controls="session-details-${initialSessionId}"]`);
row?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
return () => {
canceled = true;
cancelAnimationFrame(frame);
};
}
}, [initialSessionId, sessions, onClearInitialSession]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
if (!q) return visibleSessions; if (!q) return visibleSessions;
@@ -77,7 +93,8 @@ export function SessionsTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<input <input
type="text" type="search"
aria-label="Search sessions by title"
placeholder="Search by title..." placeholder="Search by title..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}

View File

@@ -3,6 +3,11 @@ import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector'; import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart'; import { TrendChart } from './TrendChart';
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart'; import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data'; import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
import { localDayFromMs } from '../../lib/formatters'; import { localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
@@ -116,9 +121,82 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
); );
} }
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
}
function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
onShowAll,
onHideAll,
onToggleAnime,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
}
return (
<div className="col-span-full -mt-1 mb-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
Anime Visibility
</h4>
<p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onShowAll}
>
All
</button>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-peach hover:text-ctp-peach"
onClick={onHideAll}
>
None
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{animeTitles.map((title) => {
const isVisible = !hiddenAnime.has(title);
return (
<button
key={title}
type="button"
aria-pressed={isVisible}
className={`max-w-full rounded-full border px-3 py-1 text-xs transition ${
isVisible
? 'border-ctp-blue/60 bg-ctp-blue/12 text-ctp-blue'
: 'border-ctp-surface2 bg-transparent text-ctp-subtext0'
}`}
onClick={() => onToggleAnime(title)}
title={title}
>
<span className="block truncate">{title}</span>
</button>
);
})}
</div>
</div>
);
}
export function TrendsTab() { export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d'); const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day'); const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
const { data, loading, error } = useTrends(range, groupBy); const { data, loading, error } = useTrends(range, groupBy);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
@@ -140,6 +218,24 @@ export function TrendsTab() {
const animeProgress = buildCumulativePerAnime(episodesPerAnime); const animeProgress = buildCumulativePerAnime(episodesPerAnime);
const cardsProgress = buildCumulativePerAnime(cardsPerAnime); const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
const wordsProgress = buildCumulativePerAnime(wordsPerAnime); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -168,15 +264,32 @@ export function TrendsTab() {
/> />
<SectionHeader>Anime Per Day</SectionHeader> <SectionHeader>Anime Per Day</SectionHeader>
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} /> <AnimeVisibilityFilter
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} /> animeTitles={animeTitles}
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} /> hiddenAnime={activeHiddenAnime}
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} /> onShowAll={() => 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;
})
}
/>
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
<SectionHeader>Anime Cumulative</SectionHeader> <SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Episodes Progress" data={animeProgress} /> <StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={cardsProgress} /> <StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} /> <StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader> <SectionHeader>Patterns</SectionHeader>
<TrendChart <TrendChart

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 6 },
{ epochDay: 1, animeTitle: 'Trapped in a Dating Sim', value: 20 },
];
test('buildAnimeVisibilityOptions sorts anime by combined contribution', () => {
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']);
});

View File

@@ -0,0 +1,32 @@
import type { PerAnimeDataPoint } from './StackedTrendChart';
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>();
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<string>,
): PerAnimeDataPoint[] {
if (hiddenAnime.size === 0) {
return data;
}
return data.filter((point) => !hiddenAnime.has(point.animeTitle));
}
export function pruneHiddenAnime(
hiddenAnime: ReadonlySet<string>,
availableAnime: readonly string[],
): Set<string> {
const availableSet = new Set(availableAnime);
return new Set([...hiddenAnime].filter((title) => availableSet.has(title)));
}

View File

@@ -1,7 +1,7 @@
import { useRef, useState } from 'react'; import { useRef, useState, useEffect } from 'react';
import { useKanjiDetail } from '../../hooks/useKanjiDetail'; import { useKanjiDetail } from '../../hooks/useKanjiDetail';
import { apiClient } from '../../lib/api-client'; 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'; import type { VocabularyOccurrenceEntry } from '../../types/stats';
const OCCURRENCES_PAGE_SIZE = 50; const OCCURRENCES_PAGE_SIZE = 50;
@@ -36,6 +36,16 @@ export function KanjiDetailPanel({
const [occLoaded, setOccLoaded] = useState(false); const [occLoaded, setOccLoaded] = useState(false);
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
useEffect(() => {
setOccurrences([]);
setOccLoaded(false);
setOccLoading(false);
setOccLoadingMore(false);
setOccError(null);
setHasMore(false);
requestIdRef.current++;
}, [kanjiId]);
if (kanjiId === null) return null; if (kanjiId === null) return null;
const loadOccurrences = async (kanji: string, offset: number, append: boolean) => { const loadOccurrences = async (kanji: string, offset: number, append: boolean) => {
@@ -123,13 +133,13 @@ export function KanjiDetailPanel({
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green"> <div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)} {formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div> </div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve"> <div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)} {formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div> </div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail'; import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client'; 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 { fullReading } from '../../lib/reading-utils';
import type { VocabularyOccurrenceEntry } from '../../types/stats'; import type { VocabularyOccurrenceEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers'; import { PosBadge } from './pos-helpers';
@@ -256,13 +256,13 @@ export function WordDetailPanel({
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green"> <div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)} {formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div> </div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve"> <div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)} {formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div> </div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div> </div>

View File

@@ -9,14 +9,34 @@ export function useAnimeDetail(animeId: number | null) {
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
if (animeId === null) return; let cancelled = false;
if (animeId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatsClient() getStatsClient()
.getAnimeDetail(animeId) .getAnimeDetail(animeId)
.then(setData) .then((next) => {
.catch((err: Error) => setError(err.message)) if (cancelled) return;
.finally(() => setLoading(false)); setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [animeId, reloadKey]); }, [animeId, reloadKey]);
const reload = useCallback(() => setReloadKey((k) => k + 1), []); const reload = useCallback(() => setReloadKey((k) => k + 1), []);

View File

@@ -8,14 +8,34 @@ export function useKanjiDetail(kanjiId: number | null) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (kanjiId === null) return; let cancelled = false;
if (kanjiId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatsClient() getStatsClient()
.getKanjiDetail(kanjiId) .getKanjiDetail(kanjiId)
.then(setData) .then((next) => {
.catch((err: Error) => setError(err.message)) if (cancelled) return;
.finally(() => setLoading(false)); setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [kanjiId]); }, [kanjiId]);
return { data, loading, error }; return { data, loading, error };

View File

@@ -8,14 +8,34 @@ export function useMediaDetail(videoId: number | null) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (videoId === null) return; let cancelled = false;
if (videoId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatsClient() getStatsClient()
.getMediaDetail(videoId) .getMediaDetail(videoId)
.then(setData) .then((next) => {
.catch((err: Error) => setError(err.message)) if (cancelled) return;
.finally(() => setLoading(false)); setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]); }, [videoId]);
return { data, loading, error }; return { data, loading, error };

View File

@@ -8,11 +8,26 @@ export function useMediaLibrary() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient() getStatsClient()
.getMediaLibrary() .getMediaLibrary()
.then(setMedia) .then((rows) => {
.catch((err: Error) => setError(err.message)) if (cancelled) return;
.finally(() => setLoading(false)); setMedia(rows);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []); }, []);
return { media, loading, error }; return { media, loading, error };

View File

@@ -9,14 +9,27 @@ export function useOverview() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient(); const client = getStatsClient();
Promise.all([client.getOverview(), client.getSessions(50)]) Promise.all([client.getOverview(), client.getSessions(50)])
.then(([overview, allSessions]) => { .then(([overview, allSessions]) => {
if (cancelled) return;
setData(overview); setData(overview);
setSessions(allSessions); setSessions(allSessions);
}) })
.catch((err) => setError(err.message)) .catch((err) => {
.finally(() => setLoading(false)); 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 }; return { data, sessions, loading, error };

View File

@@ -35,12 +35,19 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
const client = getStatsClient(); const client = getStatsClient();
const limitMap: Record<TimeRange, number> = { '7d': 7, '30d': 30, '90d': 90, all: 365 }; const limitMap: Record<TimeRange, number> = { '7d': 7, '30d': 30, '90d': 90, all: 365 };
const limit = limitMap[range]; const limit = limitMap[range];
const monthlyLimit = Math.max(1, Math.ceil(limit / 30)); const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
const sessionsLimitMap: Record<TimeRange, number> = {
'7d': 200,
'30d': 500,
'90d': 500,
all: 500,
};
const rollupFetcher = const rollupFetcher =
groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit); groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit);
@@ -50,23 +57,43 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
client.getEpisodesPerDay(limit), client.getEpisodesPerDay(limit),
client.getNewAnimePerDay(limit), client.getNewAnimePerDay(limit),
client.getWatchTimePerAnime(limit), client.getWatchTimePerAnime(limit),
client.getSessions(500), client.getSessions(sessionsLimitMap[range]),
client.getAnimeLibrary(), client.getAnimeLibrary(),
]) ])
.then( .then(
([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => { ([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({ setData({
rollups, rollups,
episodesPerDay, episodesPerDay,
newAnimePerDay, newAnimePerDay,
watchTimePerAnime, watchTimePerAnime,
sessions, sessions: filteredSessions,
animeLibrary, animeLibrary,
}); });
}, },
) )
.catch((err) => setError(err.message)) .catch((err) => {
.finally(() => setLoading(false)); if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [range, groupBy]); }, [range, groupBy]);
return { data, loading, error }; return { data, loading, error };

View File

@@ -10,11 +10,13 @@ export function useVocabulary() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
const client = getStatsClient(); const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()]) Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => { .then(([wordsResult, kanjiResult, knownResult]) => {
if (cancelled) return;
const errors: string[] = []; const errors: string[] = [];
if (wordsResult.status === 'fulfilled') { if (wordsResult.status === 'fulfilled') {
@@ -37,7 +39,13 @@ export function useVocabulary() {
setError(errors.join('; ')); setError(errors.join('; '));
} }
}) })
.finally(() => setLoading(false)); .finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []); }, []);
return { words, kanji, knownWords, loading, error }; return { words, kanji, knownWords, loading, error };

View File

@@ -8,14 +8,34 @@ export function useWordDetail(wordId: number | null) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (wordId === null) return; let cancelled = false;
if (wordId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatsClient() getStatsClient()
.getWordDetail(wordId) .getWordDetail(wordId)
.then(setData) .then((next) => {
.catch((err: Error) => setError(err.message)) if (cancelled) return;
.finally(() => setLoading(false)); setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [wordId]); }, [wordId]);
return { data, loading, error }; return { data, loading, error };

View File

@@ -62,15 +62,78 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
activeAnimeCount: 3, activeAnimeCount: 3,
totalEpisodesWatched: 5, totalEpisodesWatched: 5,
totalAnimeCompleted: 1, totalAnimeCompleted: 1,
totalActiveMin: 50,
activeDays: 2,
totalCards: 9,
}, },
}; };
const summary = buildOverviewSummary(overview, now); const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2); assert.equal(summary.todayCards, 2);
assert.equal(summary.totalTrackedCards, 2); assert.equal(summary.totalTrackedCards, 9);
assert.equal(summary.episodesToday, 2); assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3); assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50); 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: [
{
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: 999,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalActiveMin: 120,
activeDays: 40,
totalCards: 5,
},
};
const summary = buildOverviewSummary(overview, now);
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', () => { test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {

View File

@@ -5,7 +5,7 @@ import type {
StreakCalendarDay, StreakCalendarDay,
VocabularyEntry, VocabularyEntry,
} from '../types/stats'; } from '../types/stats';
import { epochDayToDate, localDayFromMs } from './formatters'; import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters';
export interface ChartPoint { export interface ChartPoint {
label: string; label: string;
@@ -47,6 +47,10 @@ export interface VocabularySummary {
recentDiscoveries: VocabularyEntry[]; recentDiscoveries: VocabularyEntry[];
} }
function normalizeDbTimestampSeconds(ts: number): number {
return Math.floor(epochMsFromDbTimestamp(ts) / 1000);
}
function makeRollupLabel(value: number): string { function makeRollupLabel(value: number): string {
if (value > 100_000) { if (value > 100_000) {
const year = Math.floor(value / 100); const year = Math.floor(value / 100);
@@ -135,6 +139,8 @@ export function buildOverviewSummary(
const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined); const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined);
const rollupCards = sumBy(aggregated, (row) => row.cards); 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; let streakDays = 0;
const streakStart = daysWithActivity.has(today) ? today : today - 1; const streakStart = daysWithActivity.has(today) ? today : today - 1;
@@ -155,8 +161,8 @@ export function buildOverviewSummary(
sumBy(todaySessions, (session) => session.cardsMined), sumBy(todaySessions, (session) => session.cardsMined),
), ),
streakDays, streakDays,
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60), allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)),
totalTrackedCards: Math.max(sessionCards, rollupCards), totalTrackedCards: lifetimeCards,
episodesToday: overview.hints.episodesToday ?? 0, episodesToday: overview.hints.episodesToday ?? 0,
activeAnimeCount: overview.hints.activeAnimeCount ?? 0, activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
totalEpisodesWatched: overview.hints.totalEpisodesWatched ?? 0, totalEpisodesWatched: overview.hints.totalEpisodesWatched ?? 0,
@@ -170,7 +176,7 @@ export function buildOverviewSummary(
) )
: 0, : 0,
totalSessions: overview.hints.totalSessions, totalSessions: overview.hints.totalSessions,
activeDays: daysWithActivity.size, activeDays: overview.hints.activeDays ?? daysWithActivity.size,
recentWatchTime: aggregated recentWatchTime: aggregated
.slice(-14) .slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })), .map((row) => ({ label: row.label, value: row.activeMin })),
@@ -202,14 +208,18 @@ export function buildVocabularySummary(
const byDay = new Map<number, number>(); const byDay = new Map<number, number>();
for (const word of words) { 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); byDay.set(day, (byDay.get(day) ?? 0) + 1);
} }
return { return {
uniqueWords: words.length, uniqueWords: words.length,
uniqueKanji: kanji.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] topWords: [...words]
.sort((left, right) => right.frequency - left.frequency) .sort((left, right) => right.frequency - left.frequency)
.slice(0, 12) .slice(0, 12)
@@ -222,7 +232,11 @@ export function buildVocabularySummary(
value: count, value: count,
})), })),
recentDiscoveries: [...words] 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), .slice(0, 8),
}; };
} }

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { formatRelativeDate } from './formatters'; import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
test('formatRelativeDate: future timestamps return "just now"', () => { test('formatRelativeDate: future timestamps return "just now"', () => {
assert.equal(formatRelativeDate(Date.now() + 60_000), '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'); assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
}); });
test('formatRelativeDate: 23 hours ago returns "23h ago"', () => { test('formatRelativeDate: same calendar day can return "23h ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 23 * 3_600_000), '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"', () => { test('formatRelativeDate: two calendar days ago returns "2d ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 36 * 3_600_000), 'Yesterday'); 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"', () => { 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; const ts = Date.now() - 10 * 86_400_000;
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString()); 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);
});

View File

@@ -18,14 +18,22 @@ export function formatPercent(ratio: number | null): string {
export function formatRelativeDate(ms: number): string { export function formatRelativeDate(ms: number): string {
const now = Date.now(); const now = Date.now();
const diffMs = now - ms; const diffMs = now - ms;
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'; if (diffMs < 60_000) return 'just now';
const diffMin = Math.floor(diffMs / 60_000); const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 60) return `${diffMin}m ago`; if (diffMin < 60) return `${diffMin}m ago`;
const diffHours = Math.floor(diffMs / 3_600_000); const diffHours = Math.floor(diffMs / 3_600_000);
if (diffHours < 24) return `${diffHours}h ago`; 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 (dayDiff === 1) return 'Yesterday';
if (dayDiff < 7) return `${dayDiff}d ago`;
return new Date(ms).toLocaleDateString(); return new Date(ms).toLocaleDateString();
} }
@@ -42,3 +50,26 @@ export function localDayFromMs(ms: number): number {
export function todayLocalDay(): number { export function todayLocalDay(): number {
return localDayFromMs(Date.now()); 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' } : {}),
});
}

View File

@@ -97,6 +97,9 @@ export interface OverviewData {
activeAnimeCount: number; activeAnimeCount: number;
totalEpisodesWatched: number; totalEpisodesWatched: number;
totalAnimeCompleted: number; totalAnimeCompleted: number;
totalActiveMin: number;
activeDays: number;
totalCards?: number;
}; };
} }