mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
feat: optimize stats dashboard data and components
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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), []);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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' } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ export interface OverviewData {
|
||||
activeAnimeCount: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
totalActiveMin: number;
|
||||
activeDays: number;
|
||||
totalCards?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user