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

@@ -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<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 (
<nav className="flex border-b border-ctp-surface1" role="tablist" aria-label="Stats tabs">
{TABS.map((tab) => (
<nav
className="flex border-b border-ctp-surface1"
role="tablist"
aria-label="Stats tabs"
aria-orientation="horizontal"
>
{TABS.map((tab, index) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
ref={(element) => {
tabRefs.current[index] = element;
}}
type="button"
role="tab"
aria-controls={`panel-${tab.id}`}
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onTabChange(tab.id)}
onKeyDown={(event) => onTabKeyDown(event, index)}
className={`px-4 py-2.5 text-sm font-medium transition-colors
${
activeTab === tab.id

View File

@@ -7,7 +7,11 @@ import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
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 { calendar, loading: calLoading } = useStreakCalendar(90);
@@ -34,16 +38,21 @@ export function OverviewTab() {
</div>
<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 && (
<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
fix will show here.
No lifetime card totals in the summary table yet. New cards mined after this fix will
appear here.
</div>
)}
<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="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">
{formatNumber(summary.totalSessions)}
</div>
@@ -55,33 +64,33 @@ export function OverviewTab() {
</div>
</div>
<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">
{formatNumber(summary.allTimeHours)}
</div>
</div>
<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">
{formatNumber(summary.activeDays)}
</div>
</div>
<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">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Episodes Completed
Lifetime Episodes
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>
</div>
<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">
{formatNumber(summary.totalAnimeCompleted)}
</div>
@@ -89,7 +98,7 @@ export function OverviewTab() {
</div>
</div>
<RecentSessions sessions={sessions} />
<RecentSessions sessions={sessions} onNavigateToSession={onNavigateToSession} />
</div>
);
}

View File

@@ -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<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
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 (
<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}
@@ -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 (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={(e) => {
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 (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3">
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
<button
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="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
@@ -142,28 +150,43 @@ function SessionItem({ session }: { session: SessionSummary }) {
<div className="text-ctp-overlay2">words</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);
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 mostRecentSession = group.sessions[0]!;
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return (
<div>
<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"
>
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} />
<CoverThumbnail
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
@@ -186,18 +209,25 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
>
{'\u25B8'}
</div>
</button>
{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) => (
<div
<button
type="button"
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="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'}
@@ -220,7 +250,7 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</div>
</button>
))}
</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) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -253,7 +283,7 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
</div>
<div className="space-y-2">
{animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} />
<AnimeGroupRow key={group.key} group={group} onNavigateToSession={onNavigateToSession} />
))}
</div>
</div>

View File

@@ -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 (
<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}
@@ -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 (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
src={src}
alt=""
loading="lazy"
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
@@ -47,12 +60,16 @@ export function SessionRow({
<div className="relative group">
<button
type="button"
onClick={onToggle}
aria-expanded={isExpanded}
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"
>
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
onClick={onToggle}
aria-expanded={isExpanded}
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"
>
<CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}

View File

@@ -4,38 +4,31 @@ import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { todayLocalDay, localDayFromMs } from '../../lib/formatters';
import { formatSessionDayLabel } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
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]);
}
}
return groups;
}
export function SessionsTab() {
interface SessionsTabProps {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
}
export function SessionsTab({ initialSessionId, onClearInitialSession }: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState('');
@@ -47,6 +40,29 @@ export function SessionsTab() {
setVisibleSessions(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 q = search.trim().toLowerCase();
if (!q) return visibleSessions;
@@ -77,7 +93,8 @@ export function SessionsTab() {
return (
<div className="space-y-4">
<input
type="text"
type="search"
aria-label="Search sessions by title"
placeholder="Search by title..."
value={search}
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 { TrendChart } from './TrendChart';
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
import { localDayFromMs } from '../../lib/formatters';
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() {
const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
const { data, loading, error } = useTrends(range, groupBy);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
@@ -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 (
<div className="space-y-4">
@@ -168,15 +264,32 @@ export function TrendsTab() {
/>
<SectionHeader>Anime Per Day</SectionHeader>
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
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>
<StackedTrendChart title="Episodes Progress" data={animeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={cardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader>
<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 { 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({
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)}
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)}
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>

View File

@@ -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({
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)}
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)}
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>