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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,25 +52,88 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
lookupHitRate: 0.8,
},
];
const overview: OverviewData = {
sessions,
rollups,
hints: {
totalSessions: 1,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
totalActiveMin: 50,
activeDays: 2,
totalCards: 9,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2);
assert.equal(summary.totalTrackedCards, 9);
assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50);
assert.equal(summary.allTimeHours, 1);
assert.equal(summary.activeDays, 2);
});
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const overview: OverviewData = {
sessions,
rollups,
sessions: [
{
sessionId: 2,
canonicalTitle: 'B',
videoId: 2,
animeId: null,
animeTitle: null,
startedAtMs: now - 60_000,
endedAtMs: now,
totalWatchedMs: 60_000,
activeWatchedMs: 60_000,
linesSeen: 10,
wordsSeen: 10,
tokensSeen: 10,
cardsMined: 10,
lookupCount: 1,
lookupHits: 1,
},
],
rollups: [
{
rollupDayOrMonth: today,
videoId: 2,
totalSessions: 1,
totalActiveMin: 1,
totalLinesSeen: 10,
totalWordsSeen: 10,
totalTokensSeen: 10,
totalCards: 10,
cardsPerHour: 600,
wordsPerMin: 10,
lookupHitRate: 1,
},
],
hints: {
totalSessions: 1,
totalSessions: 999,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalActiveMin: 120,
activeDays: 40,
totalCards: 5,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2);
assert.equal(summary.totalTrackedCards, 2);
assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50);
assert.equal(summary.totalTrackedCards, 5);
assert.equal(summary.totalSessions, 999);
assert.equal(summary.allTimeHours, 2);
assert.equal(summary.activeDays, 40);
});
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {

View File

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

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatRelativeDate } from './formatters';
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
test('formatRelativeDate: future timestamps return "just now"', () => {
assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now');
@@ -27,12 +27,28 @@ test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
});
test('formatRelativeDate: 23 hours ago returns "23h ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 23 * 3_600_000), '23h ago');
test('formatRelativeDate: same calendar day can return "23h ago"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 23, 30, 0).getTime();
const sameDayMorning = new Date(2026, 2, 16, 0, 30, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(sameDayMorning), '23h ago');
} finally {
Date.now = realNow;
}
});
test('formatRelativeDate: 36 hours ago returns "Yesterday"', () => {
assert.equal(formatRelativeDate(Date.now() - 36 * 3_600_000), 'Yesterday');
test('formatRelativeDate: two calendar days ago returns "2d ago"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 12, 0, 0).getTime();
const twoDaysAgo = new Date(2026, 2, 14, 0, 0, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(twoDaysAgo), '2d ago');
} finally {
Date.now = realNow;
}
});
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
@@ -43,3 +59,43 @@ test('formatRelativeDate: 10 days ago returns locale date string', () => {
const ts = Date.now() - 10 * 86_400_000;
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
});
test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 0, 30, 0).getTime();
const previousDayLate = new Date(2026, 2, 15, 23, 45, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(previousDayLate), 'Yesterday');
} finally {
Date.now = realNow;
}
});
test('epochMsFromDbTimestamp converts seconds to ms', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000), 1_700_000_000_000);
});
test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
});
test('formatSessionDayLabel formats today and yesterday', () => {
const now = Date.now();
const oneDayMs = 24 * 60 * 60_000;
assert.equal(formatSessionDayLabel(now), 'Today');
assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday');
});
test('formatSessionDayLabel includes year for past-year dates', () => {
const now = new Date();
const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime();
const label = formatSessionDayLabel(sameDayLastYear);
const year = new Date(sameDayLastYear).getFullYear();
assert.ok(label.includes(String(year)));
const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
});
assert.notEqual(label, withoutYear);
});

View File

@@ -18,14 +18,22 @@ export function formatPercent(ratio: number | null): string {
export function formatRelativeDate(ms: number): string {
const now = Date.now();
const diffMs = now - ms;
if (diffMs < 60_000) return 'just now';
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHours = Math.floor(diffMs / 3_600_000);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffDays < 2) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
if (diffMs <= 0) return 'just now';
const nowDay = localDayFromMs(now);
const sessionDay = localDayFromMs(ms);
const dayDiff = nowDay - sessionDay;
if (dayDiff <= 0) {
if (diffMs < 60_000) return 'just now';
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHours = Math.floor(diffMs / 3_600_000);
return `${diffHours}h ago`;
}
if (dayDiff === 1) return 'Yesterday';
if (dayDiff < 7) return `${dayDiff}d ago`;
return new Date(ms).toLocaleDateString();
}
@@ -42,3 +50,26 @@ export function localDayFromMs(ms: number): number {
export function todayLocalDay(): number {
return localDayFromMs(Date.now());
}
// Immersion tracker stores word/kanji first_seen/last_seen as epoch seconds.
// Older fixtures or callers may still pass ms, so normalize defensively.
export function epochMsFromDbTimestamp(ts: number): number {
if (!Number.isFinite(ts)) return 0;
return ts < 10_000_000_000 ? Math.round(ts * 1000) : Math.round(ts);
}
export function formatSessionDayLabel(sessionStartedAtMs: number): string {
const today = todayLocalDay();
const day = localDayFromMs(sessionStartedAtMs);
if (day === today) return 'Today';
if (day === today - 1) return 'Yesterday';
const date = new Date(sessionStartedAtMs);
const includeYear = date.getFullYear() !== new Date().getFullYear();
return date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
});
}

View File

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