mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
feat: optimize stats dashboard data and components
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
47
stats/src/components/trends/anime-visibility.test.ts
Normal file
47
stats/src/components/trends/anime-visibility.test.ts
Normal 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']);
|
||||
});
|
||||
32
stats/src/components/trends/anime-visibility.ts
Normal file
32
stats/src/components/trends/anime-visibility.ts
Normal 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)));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user