feat(stats): speed up session maintenance and improve stats UI (#111)

This commit is contained in:
2026-06-08 02:20:52 -07:00
committed by GitHub
parent e6a16a069b
commit 311f1e8ee5
108 changed files with 7441 additions and 729 deletions
+19
View File
@@ -30,6 +30,11 @@ const VocabularyTab = lazy(() =>
default: module.VocabularyTab,
})),
);
const SearchTab = lazy(() =>
import('./components/search/SearchTab').then((module) => ({
default: module.SearchTab,
})),
);
const SessionsTab = lazy(() =>
import('./components/sessions/SessionsTab').then((module) => ({
default: module.SessionsTab,
@@ -183,6 +188,7 @@ export function App() {
<OverviewTab
onNavigateToMediaDetail={navigateToOverviewMediaDetail}
onNavigateToSession={navigateToSession}
isActive={activeTab === 'overview'}
/>
</section>
) : null}
@@ -239,6 +245,19 @@ export function App() {
</Suspense>
</section>
) : null}
{mountedTabs.has('search') ? (
<section
id="panel-search"
role="tabpanel"
aria-labelledby="tab-search"
hidden={activeTab !== 'search'}
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading search..." />}>
<SearchTab />
</Suspense>
</section>
) : null}
{mountedTabs.has('sessions') ? (
<section
id="panel-sessions"
@@ -29,7 +29,7 @@ export function AnilistSelector({
const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
@@ -53,7 +53,7 @@ export function AnilistSelector({
const handleInput = (value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400);
};
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeCard } from './AnimeCard';
test('AnimeCard includes linked AniList id in cover URLs to avoid stale library covers', () => {
const markup = renderToStaticMarkup(
<AnimeCard
anime={{
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
totalSessions: 1,
totalActiveMs: 600_000,
totalCards: 0,
totalTokensSeen: 100,
episodeCount: 1,
episodesTotal: 10,
lastWatchedMs: 1_000,
}}
onClick={() => {}}
/>,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=21699/);
});
+1
View File
@@ -18,6 +18,7 @@ export function AnimeCard({ anime, onClick }: AnimeCardProps) {
<AnimeCoverImage
animeId={anime.animeId}
title={anime.canonicalTitle}
coverRetryToken={anime.anilistId ?? 0}
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
/>
</div>
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeCoverImage } from './AnimeCoverImage';
import { AnimeHeader } from './AnimeHeader';
test('AnimeCoverImage includes manual relink cover retry tokens', () => {
const markup = renderToStaticMarkup(
<AnimeCoverImage animeId={42} title="Test Anime" coverRetryToken={7} />,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=7/);
});
test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
detail={{
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 0,
totalActiveMs: 0,
totalCards: 0,
totalTokensSeen: 0,
totalLinesSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 0,
}}
anilistEntries={[]}
/>,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=21699/);
});
+10 -25
View File
@@ -1,35 +1,20 @@
import { useState } from 'react';
import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { getStatsClient } from '../../hooks/useStatsApi';
interface AnimeCoverImageProps {
animeId: number;
title: string;
coverRetryToken?: number;
className?: string;
}
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
export function AnimeCoverImage({
animeId,
title,
coverRetryToken = 0,
className = '',
}: AnimeCoverImageProps) {
const src = getStatsClient().getAnimeCoverUrl(animeId, coverRetryToken);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
const src = getStatsClient().getAnimeCoverUrl(animeId);
return (
<img
src={src}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
);
return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
}
@@ -142,8 +142,13 @@ export function AnimeDetailView({
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const knownWordsSummary = useAnimeKnownWords(animeId);
useEffect(() => {
setCoverRetryToken(0);
}, [animeId]);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
@@ -161,6 +166,7 @@ export function AnimeDetailView({
<AnimeHeader
detail={detail}
anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)}
/>
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
@@ -177,6 +183,7 @@ export function AnimeDetailView({
onClose={() => setShowAnilistSelector(false)}
onLinked={() => {
setShowAnilistSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
}}
/>
+9 -1
View File
@@ -4,6 +4,7 @@ import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
interface AnimeHeaderProps {
detail: AnimeDetailData['detail'];
anilistEntries: AnilistEntry[];
coverRetryToken?: number;
onChangeAnilist?: () => void;
}
@@ -26,19 +27,26 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
);
}
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
export function AnimeHeader({
detail,
anilistEntries,
coverRetryToken = 0,
onChangeAnilist,
}: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle,
);
const uniqueAltTitles = [...new Set(altTitles)];
const hasMultipleEntries = anilistEntries.length > 1;
const coverCacheToken = (detail.anilistId ?? 0) * 1_000_000 + coverRetryToken;
return (
<div className="flex gap-4">
<AnimeCoverImage
animeId={detail.animeId}
title={detail.canonicalTitle}
coverRetryToken={coverCacheToken}
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
+21 -4
View File
@@ -1,13 +1,18 @@
import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters';
import {
getLibraryCardSizeStorage,
readLibraryCardSizePreference,
type LibraryCardSize,
writeLibraryCardSizePreference,
} from '../../lib/library-card-size';
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
type CardSize = 'sm' | 'md' | 'lg';
const GRID_CLASSES: Record<CardSize, string> = {
const GRID_CLASSES: Record<LibraryCardSize, string> = {
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
@@ -51,9 +56,21 @@ export function AnimeTab({
const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<CardSize>('md');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
readLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
),
);
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
function handleCardSizeChange(size: LibraryCardSize): void {
setCardSize(size);
writeLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
size,
);
}
useEffect(() => {
if (initialAnimeId != null) {
setSelectedAnimeId(initialAnimeId);
@@ -113,7 +130,7 @@ export function AnimeTab({
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
onClick={() => setCardSize(size)}
onClick={() => handleCardSizeChange(size)}
className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
@@ -0,0 +1,32 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
/**
* Fixed-position toast shown while session deletions are in flight.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
if (count <= 0) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
</div>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { appendCoverRetryToken, getCoverRetryDelayMs } from '../../lib/cover-retry';
interface RetryingCoverImageProps {
src: string;
alt: string;
fallbackLabel: string;
className?: string;
fallbackTextClassName?: string;
loading?: 'eager' | 'lazy';
}
export function RetryingCoverImage({
src,
alt,
fallbackLabel,
className = '',
fallbackTextClassName = 'text-2xl',
loading = 'lazy',
}: RetryingCoverImageProps) {
const [failed, setFailed] = useState(false);
const [retryToken, setRetryToken] = useState(0);
const fallbackChar = fallbackLabel.charAt(0) || '?';
useEffect(() => {
setFailed(false);
setRetryToken(0);
}, [src]);
useEffect(() => {
if (!failed) return;
const timer = setTimeout(() => {
setRetryToken((value) => value + 1);
setFailed(false);
}, getCoverRetryDelayMs(retryToken));
return () => clearTimeout(timer);
}, [failed, retryToken]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 ${fallbackTextClassName} font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={appendCoverRetryToken(src, retryToken)}
alt={alt}
loading={loading}
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
/>
);
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { useRef, type KeyboardEvent } from 'react';
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'search' | 'sessions';
interface Tab {
id: TabId;
@@ -12,6 +12,7 @@ const TABS: Tab[] = [
{ id: 'anime', label: 'Library' },
{ id: 'trends', label: 'Trends' },
{ id: 'vocabulary', label: 'Vocabulary' },
{ id: 'search', label: 'Search' },
{ id: 'sessions', label: 'Sessions' },
];
+2 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
interface CoverImageProps {
@@ -9,31 +9,9 @@ interface CoverImageProps {
}
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
useEffect(() => {
setFailed(false);
}, [resolvedSrc]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={resolvedSrc}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
<RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { getCoverImageSrc, type CoverImageMap } from '../../lib/cover-images';
interface CoverThumbnailProps {
animeId: number | null;
videoId: number | null;
title: string;
coverImages: CoverImageMap;
}
export function CoverThumbnail({ animeId, videoId, title, coverImages }: CoverThumbnailProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const fallback = (
<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}
</div>
);
const src =
animeId != null
? getCoverImageSrc(coverImages, 'anime', animeId)
: getCoverImageSrc(coverImages, 'media', videoId);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return fallback;
}
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setFailed(true)}
/>
);
}
+10 -1
View File
@@ -6,6 +6,7 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
@@ -19,9 +20,14 @@ import type { SessionSummary } from '../../types/stats';
interface OverviewTabProps {
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
isActive?: boolean;
}
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
export function OverviewTab({
onNavigateToMediaDetail,
onNavigateToSession,
isActive = true,
}: OverviewTabProps) {
const { data, sessions, setSessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -152,7 +158,10 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
onDeleteDayGroup={handleDeleteDayGroup}
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
isActive={isActive}
/>
<DeleteProgressToast count={deletingIds.size} />
</div>
);
}
@@ -5,7 +5,9 @@ import {
formatNumber,
formatSessionDayLabel,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import { CoverThumbnail } from './CoverThumbnail';
import { useCoverImages } from '../../hooks/useCoverImages';
import type { CoverImageMap } from '../../lib/cover-images';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
@@ -18,6 +20,7 @@ interface RecentSessionsProps {
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
deletingIds: Set<number>;
isActive?: boolean;
}
interface AnimeGroup {
@@ -85,53 +88,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
return Array.from(map.values());
}
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
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}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setIsFallback(true)}
/>
);
}
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
coverImages,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
coverImages: CoverImageMap;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
@@ -153,6 +123,7 @@ function SessionItem({
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
@@ -205,6 +176,7 @@ function AnimeGroupRow({
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
coverImages,
}: {
group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
@@ -212,6 +184,7 @@ function AnimeGroupRow({
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
coverImages: CoverImageMap;
}) {
const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
@@ -225,6 +198,7 @@ function AnimeGroupRow({
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
coverImages={coverImages}
/>
);
}
@@ -247,6 +221,7 @@ function AnimeGroupRow({
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
@@ -319,6 +294,7 @@ function AnimeGroupRow({
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
@@ -377,7 +353,10 @@ export function RecentSessions({
onDeleteDayGroup,
onDeleteAnimeGroup,
deletingIds,
isActive = true,
}: RecentSessionsProps) {
const coverImages = useCoverImages(sessions, { enabled: isActive });
if (sessions.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -422,6 +401,7 @@ export function RecentSessions({
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
coverImages={coverImages}
/>
))}
</div>
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { formatSentenceSearchMatchCountLabel } from './SearchTab';
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
test('formatSentenceSearchMatchCountLabel uses singular label for one result', () => {
assert.equal(formatSentenceSearchMatchCountLabel(1), 'Match');
assert.equal(formatSentenceSearchMatchCountLabel(0), 'Matches');
assert.equal(formatSentenceSearchMatchCountLabel(2), 'Matches');
});
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /buildStatsMineCardParams\(result,\s*searchedWord,\s*mode\)/);
});
test('SearchTab enables headword sentence search by default and forwards the toggle', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /const \[searchByHeadword,\s*setSearchByHeadword\] = useState\(true\);/);
assert.match(
source,
/apiClient\s*\.\s*searchSentences\(trimmed,\s*SEARCH_LIMIT,\s*searchByHeadword\)/,
);
assert.match(source, /checked=\{searchByHeadword\}/);
assert.match(source, /setSearchByHeadword\(event\.target\.checked\)/);
});
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import {
getSentenceSearchMineAvailability,
renderSentenceWithMatches,
} from '../../lib/sentence-search';
import { buildStatsMineCardParams, getStatsMineCardError } from '../../lib/mining';
import type { SentenceSearchResult } from '../../types/stats';
const SEARCH_LIMIT = 50;
const SEARCH_DEBOUNCE_MS = 160;
type MineMode = 'word' | 'sentence' | 'audio';
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function resultKey(result: SentenceSearchResult, index: number): string {
return `${result.sessionId}-${result.lineIndex}-${result.segmentStartMs ?? index}`;
}
function statusKey(result: SentenceSearchResult, index: number, mode: MineMode): string {
return `${resultKey(result, index)}-${mode}`;
}
function buttonLabel(
mode: MineMode,
status: MineStatus | undefined,
disabledLabel: string,
): string {
if (status?.loading) return 'Mining...';
if (status?.success) return 'Mined!';
if (disabledLabel) return disabledLabel;
if (mode === 'word') return 'Word';
if (mode === 'audio') return 'Audio';
return 'Sentence';
}
export function formatSentenceSearchMatchCountLabel(count: number): string {
return count === 1 ? 'Match' : 'Matches';
}
export function SearchTab() {
const [query, setQuery] = useState('');
const [searchByHeadword, setSearchByHeadword] = useState(true);
const [results, setResults] = useState<SentenceSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
const requestRef = useRef(0);
useEffect(() => {
const trimmed = query.trim();
const requestId = ++requestRef.current;
setMineStatus({});
if (!trimmed) {
setResults([]);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
const timer = window.setTimeout(() => {
apiClient
.searchSentences(trimmed, SEARCH_LIMIT, searchByHeadword)
.then((nextResults) => {
if (requestId !== requestRef.current) return;
setResults(nextResults);
})
.catch((err: Error) => {
if (requestId !== requestRef.current) return;
setError(err.message);
setResults([]);
})
.finally(() => {
if (requestId !== requestRef.current) return;
setLoading(false);
});
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [query, searchByHeadword]);
const handleMine = async (
result: SentenceSearchResult,
index: number,
mode: MineMode,
): Promise<void> => {
const availability = getSentenceSearchMineAvailability(result, query);
if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) {
return;
}
const searchedWord = availability.exactMatch ? query.trim() : '';
const params = buildStatsMineCardParams(result, searchedWord, mode);
if (!params) {
return;
}
const key = statusKey(result, index, mode);
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
const response = await apiClient.mineCard(params);
const responseError = getStatsMineCardError(response);
if (responseError) {
setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } }));
return;
}
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
} catch (err) {
setMineStatus((prev) => ({
...prev,
[key]: { error: err instanceof Error ? err.message : String(err) },
}));
}
};
const trimmedQuery = query.trim();
return (
<div className="space-y-4">
<section className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/70 p-4">
<span className="mb-3 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
Sentence Search
</span>
<div className="flex items-stretch gap-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search sentence text or media..."
className="min-w-0 flex-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
autoComplete="off"
aria-label="Sentence search"
/>
<div className="flex min-w-[5rem] flex-col items-center justify-center rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2">
<div className="text-xl font-semibold leading-none text-ctp-yellow">
{loading ? '...' : results.length}
</div>
<div className="mt-1 text-[11px] uppercase tracking-wide text-ctp-overlay1">
{loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)}
</div>
</div>
</div>
<label className="mt-3 inline-flex cursor-pointer items-center gap-2 text-xs font-medium text-ctp-overlay1">
<input
type="checkbox"
checked={searchByHeadword}
onChange={(event) => setSearchByHeadword(event.target.checked)}
className="h-4 w-4 rounded border-ctp-surface2 bg-ctp-surface0 text-ctp-yellow focus:ring-ctp-yellow"
/>
Search by headword
</label>
</section>
{error && (
<div className="rounded-lg border border-ctp-red/40 bg-ctp-red/10 p-3 text-sm text-ctp-red">
Error: {error}
</div>
)}
{!trimmedQuery && (
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
Search your tracked subtitle lines.
</div>
)}
{trimmedQuery && !loading && !error && results.length === 0 && (
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
No sentence matches.
</div>
)}
{results.length > 0 && (
<div className="grid gap-3">
{results.map((result, index) => {
const availability = getSentenceSearchMineAvailability(result, trimmedQuery);
const wordStatus = mineStatus[statusKey(result, index, 'word')];
const sentenceStatus = mineStatus[statusKey(result, index, 'sentence')];
const audioStatus = mineStatus[statusKey(result, index, 'audio')];
const wordAudioDisabledReason =
availability.unavailableReason ??
(availability.exactMatch ? '' : 'Exact searched word not found in sentence.');
const sentenceDisabledReason = availability.unavailableReason ?? '';
const errors = [wordStatus?.error, sentenceStatus?.error, audioStatus?.error].filter(
Boolean,
);
return (
<article
key={resultKey(result, index)}
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/90 p-4 shadow-lg shadow-ctp-crust/20"
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{result.animeTitle ?? result.videoTitle}
</div>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-ctp-overlay1">
<span className="max-w-full truncate">{result.videoTitle}</span>
<span>line {result.lineIndex}</span>
<span>session {result.sessionId}</span>
<span>
{formatSegment(result.segmentStartMs)}-{formatSegment(result.segmentEndMs)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={wordStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'word')}
>
{buttonLabel(
'word',
wordStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
<button
type="button"
title={sentenceDisabledReason || 'Create a sentence card from this line'}
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={sentenceStatus?.loading || !availability.canMineSentence}
onClick={() => void handleMine(result, index, 'sentence')}
>
{buttonLabel(
'sentence',
sentenceStatus,
availability.canMineSentence ? '' : 'Unavailable',
)}
</button>
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={audioStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'audio')}
>
{buttonLabel(
'audio',
audioStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
</div>
</div>
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
{renderSentenceWithMatches(result.text, trimmedQuery)}
</p>
{errors.length > 0 && <div className="mt-2 text-xs text-ctp-red">{errors[0]}</div>}
</article>
);
})}
</div>
)}
</div>
);
}
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { SessionBucket } from '../../lib/session-grouping';
import type { SessionSummary } from '../../types/stats';
import { buildBucketDeleteHandler } from './SessionsTab';
import {
buildBucketDeleteHandler,
decrementDeletingSessionCounts,
incrementDeletingSessionCounts,
} from './SessionsTab';
function makeSession(over: Partial<SessionSummary>): SessionSummary {
return {
@@ -71,6 +75,84 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
assert.equal(onErrorCalled, false);
});
test('buildBucketDeleteHandler signals deleted session IDs after confirm, before deleting', async () => {
const events: string[] = [];
let startedIds: number[] | null = null;
const bucket = makeBucket([
makeSession({ sessionId: 11 }),
makeSession({ sessionId: 22 }),
makeSession({ sessionId: 33 }),
]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: {
deleteSessions: async () => {
events.push('delete');
},
},
confirm: () => {
events.push('confirm');
return true;
},
onStart: (ids) => {
startedIds = ids;
events.push('start');
},
onSuccess: () => {
events.push('success');
},
onError: () => {
events.push('error');
},
});
await handler();
assert.deepEqual(events, ['confirm', 'start', 'delete', 'success']);
assert.deepEqual(startedIds, [11, 22, 33]);
});
test('deleting session counts keep rows disabled during overlapping delete flows', () => {
let deleting = new Map<number, number>();
deleting = incrementDeletingSessionCounts(deleting, [11]);
deleting = incrementDeletingSessionCounts(deleting, [11, 22]);
assert.equal(deleting.get(11), 2);
assert.equal(deleting.get(22), 1);
deleting = decrementDeletingSessionCounts(deleting, [11]);
assert.equal(deleting.get(11), 1);
assert.equal(deleting.has(22), true);
deleting = decrementDeletingSessionCounts(deleting, [11, 22]);
assert.equal(deleting.size, 0);
});
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
let startCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: { deleteSessions: async () => {} },
confirm: () => false,
onStart: () => {
startCalled = true;
},
onSuccess: () => {},
onError: () => {},
});
await handler();
assert.equal(startCalled, false);
});
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
let deleteCalled = false;
let successCalled = false;
+47 -6
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
bucket: SessionBucket;
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */
onStart?: (sessionIds: number[]) => void;
onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void;
}
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
* rendering the full SessionsTab or mocking React state.
*/
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId);
try {
if (!(await confirm(title, ids.length))) return;
onStart?.(ids);
await client.deleteSessions(ids);
onSuccess(ids);
} catch (err) {
@@ -53,6 +57,29 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
};
}
export function incrementDeletingSessionCounts(
prev: ReadonlyMap<number, number>,
ids: number[],
): Map<number, number> {
const next = new Map(prev);
for (const id of ids) next.set(id, (next.get(id) ?? 0) + 1);
return next;
}
export function decrementDeletingSessionCounts(
prev: ReadonlyMap<number, number>,
ids: number[],
): Map<number, number> {
const next = new Map(prev);
for (const id of ids) {
const count = next.get(id);
if (!count) continue;
if (count <= 1) next.delete(id);
else next.set(id, count - 1);
}
return next;
}
interface SessionsTabProps {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
@@ -70,7 +97,9 @@ export function SessionsTab({
const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [deletingSessionIds, setDeletingSessionIds] = useState<Map<number, number>>(
() => new Map(),
);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
useEffect(() => {
@@ -119,6 +148,14 @@ export function SessionsTab({
});
};
const markDeleting = (ids: number[]) => {
setDeletingSessionIds((prev) => incrementDeletingSessionCounts(prev, ids));
};
const unmarkDeleting = (ids: number[]) => {
setDeletingSessionIds((prev) => decrementDeletingSessionCounts(prev, ids));
};
const handleDeleteSession = async (session: SessionSummary) => {
let confirmed = false;
try {
@@ -130,7 +167,7 @@ export function SessionsTab({
if (!confirmed) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);
markDeleting([session.sessionId]);
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
@@ -138,7 +175,7 @@ export function SessionsTab({
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
unmarkDeleting([session.sessionId]);
}
};
@@ -149,6 +186,7 @@ export function SessionsTab({
bucket,
apiClient,
confirm: confirmBucketDelete,
onStart: (ids) => markDeleting(ids),
onSuccess: (ids) => {
const deleted = new Set(ids);
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
@@ -166,6 +204,7 @@ export function SessionsTab({
await handler();
} finally {
setDeletingBucketKey(null);
unmarkDeleting(bucket.sessions.map((session) => session.sessionId));
}
};
@@ -210,7 +249,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
@@ -279,7 +318,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
@@ -305,6 +344,8 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
<DeleteProgressToast count={deletingSessionIds.size} />
</div>
);
}
@@ -17,3 +17,10 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
assert.match(markup, /Title Visibility/);
assert.doesNotMatch(markup, /Anime Visibility/);
});
test('TrendsTab source labels words per minute without reading speed wording', async () => {
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
assert.match(source, /title="Words \/ Min"/);
assert.doesNotMatch(source, /Reading Speed/);
});
+67 -36
View File
@@ -148,7 +148,7 @@ export function TrendsTab() {
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader>
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.activity.watchTime}
@@ -163,6 +163,72 @@ export function TrendsTab() {
/>
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Cumulative Totals</SectionHeader>
<TrendChart
title="Watch Time, cumulative (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart
title="Words Seen (cumulative)"
data={data.progress.words}
color="#8bd5ca"
type="line"
/>
<TrendChart
title="New Words Seen (cumulative)"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined (cumulative)"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched (cumulative)"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart
title="Sessions (cumulative)"
data={data.progress.sessions}
color="#b7bdf8"
type="line"
/>
<TrendChart
title="Lookups (cumulative)"
data={data.progress.lookups}
color="#f5bde6"
type="line"
/>
<SectionHeader>Efficiency</SectionHeader>
<TrendChart
title="Words / Min"
data={data.ratios.readingSpeed}
color="#a6da95"
type="line"
/>
<TrendChart
title="Cards / Hour"
data={data.ratios.cardsPerHour}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={data.patterns.watchTimeByDayOfWeek}
@@ -176,41 +242,6 @@ export function TrendsTab() {
type="bar"
/>
<SectionHeader>Period Trends</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart
title="New Words Seen"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Library Cumulative</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { buildCrossAnimeWordRows, CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import type { VocabularyEntry } from '../../types/stats';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
return {
wordId: 1,
headword: '日本語',
word: '日本語',
reading: 'にほんご',
frequency: 5,
frequencyRank: 100,
animeCount: 2,
partOfSpeech: null,
firstSeen: 0,
lastSeen: 0,
...over,
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('cross-title rows can hide kana-only headwords', () => {
const rows = buildCrossAnimeWordRows(
[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', reading: 'さらに' }),
makeEntry({ wordId: 2, headword: '前に', word: '前に', reading: 'まえに' }),
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ' }),
],
new Set(),
{ hideKnown: false, hideKanaOnly: true },
);
assert.deepEqual(
rows.map((row) => row.headword),
['前に'],
);
});
test('cross-title table renders a Hide Kana filter button', () => {
const markup = renderToStaticMarkup(
<CrossAnimeWordsTable
words={[makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' })]}
knownWords={new Set()}
/>,
);
assert.match(markup, /Hide Kana/);
});
test('cross-title table uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.crossAnimeWords.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<CrossAnimeWordsTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', reading: 'さらに' }),
makeEntry({ wordId: 2, headword: '前に', word: '前に', reading: 'まえに' }),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { isKanaOnlyTokenText } from '../../lib/kana-token';
import { readBooleanPreference, writeBooleanPreference } from '../../lib/preference-storage';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats';
@@ -10,6 +12,59 @@ interface CrossAnimeWordsTableProps {
}
const PAGE_SIZE = 25;
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.crossAnimeWords.hideKanaOnly';
interface CrossAnimeWordsOptions {
hideKnown: boolean;
hideKanaOnly: boolean;
}
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
return knownWords.has(w.headword) || knownWords.has(w.word);
}
function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
export function buildCrossAnimeWordRows(
words: VocabularyEntry[],
knownWords: Set<string>,
options: CrossAnimeWordsOptions,
): VocabularyEntry[] {
const hasKnownData = knownWords.size > 0;
let filtered = words.filter((w) => w.animeCount >= 2);
if (options.hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
}
if (options.hideKanaOnly) {
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (
w.frequencyRank != null &&
(existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank)
) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) existing.reading = w.reading;
if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech;
}
}
return [...byHeadword.values()].sort((a, b) => {
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
return b.frequency - a.frequency;
});
}
export function CrossAnimeWordsTable({
words,
@@ -18,40 +73,16 @@ export function CrossAnimeWordsTable({
}: CrossAnimeWordsTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.animeCount >= 2);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !knownWords.has(w.headword) && !knownWords.has(w.word));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (
w.frequencyRank != null &&
(existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank)
) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) existing.reading = w.reading;
if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech;
}
}
return [...byHeadword.values()].sort((a, b) => {
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
return b.frequency - a.frequency;
});
}, [words, knownWords, hideKnown, hasKnownData]);
return buildCrossAnimeWordRows(words, knownWords, { hideKnown, hideKanaOnly });
}, [words, knownWords, hideKnown, hideKanaOnly]);
const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2);
if (!hasMultiAnimeWords) return null;
@@ -74,10 +105,11 @@ export function CrossAnimeWordsTable({
</span>
Words Across Multiple Titles
</button>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-2">
{hasKnownData && (
<button
type="button"
aria-pressed={hideKnown}
onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
@@ -91,14 +123,33 @@ export function CrossAnimeWordsTable({
Hide Known
</button>
)}
<button
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKanaOnly
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Kana
</button>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown
{hideKnown && hasKnownData && !hideKanaOnly
? 'All words that span multiple titles are already known!'
: 'No words found across multiple titles.'}
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No words across multiple titles match the active filters.'
: 'No words found across multiple titles.'}
</div>
) : (
<>
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { FrequencyRankTable } from './FrequencyRankTable';
import {
buildFrequencyRankRows,
FrequencyRankTable,
isKanaOnlyTokenText,
} from './FrequencyRankTable';
import type { VocabularyEntry } from '../../types/stats';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
@@ -20,6 +24,46 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
const entry = makeEntry({});
const markup = renderToStaticMarkup(
@@ -41,3 +85,79 @@ test('omits reading when reading equals headword', () => {
'should not render any bracketed reading when equal to headword',
);
});
test('identifies kana-only token text without hiding mixed kanji words', () => {
assert.equal(isKanaOnlyTokenText('さらに'), true);
assert.equal(isKanaOnlyTokenText('バカ'), true);
assert.equal(isKanaOnlyTokenText('カレー'), true);
assert.equal(isKanaOnlyTokenText('前に'), false);
assert.equal(isKanaOnlyTokenText('間違いない'), false);
});
test('frequency rows can hide kana-only headwords', () => {
const rows = buildFrequencyRankRows(
[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ', frequencyRank: 30 }),
],
new Set(),
{ hideKnown: false, hideKanaOnly: true },
);
assert.deepEqual(
rows.map((row) => row.headword),
['前に'],
);
});
test('renders a Hide Kana filter button', () => {
const entry = makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' });
const markup = renderToStaticMarkup(
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
);
assert.match(markup, /Hide Kana/);
});
test('uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
test('uses saved Hide Known preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKnown': 'false' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[makeEntry({ headword: '日本語', word: '日本語', frequencyRank: 10 })]}
knownWords={new Set(['日本語'])}
/>,
),
);
assert.match(markup, />Most Common Words Seen</);
assert.match(markup, />日本語</);
});
@@ -1,6 +1,8 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils';
import { isKanaOnlyTokenText } from '../../lib/kana-token';
import { readBooleanPreference, writeBooleanPreference } from '../../lib/preference-storage';
import type { VocabularyEntry } from '../../types/stats';
interface FrequencyRankTableProps {
@@ -10,46 +12,76 @@ interface FrequencyRankTableProps {
}
const PAGE_SIZE = 25;
const HIDE_KNOWN_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKnown';
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKanaOnly';
interface FrequencyRankOptions {
hideKnown: boolean;
hideKanaOnly: boolean;
}
export { isKanaOnlyTokenText };
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
return knownWords.has(w.headword) || knownWords.has(w.word);
}
function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
export function buildFrequencyRankRows(
words: VocabularyEntry[],
knownWords: Set<string>,
options: FrequencyRankOptions,
): VocabularyEntry[] {
const hasKnownData = knownWords.size > 0;
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (options.hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
}
if (options.hideKanaOnly) {
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (w.frequencyRank! < existing.frequencyRank!) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) {
existing.reading = w.reading;
}
if (!existing.partOfSpeech && w.partOfSpeech) {
existing.partOfSpeech = w.partOfSpeech;
}
}
}
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [hideKnown, setHideKnown] = useState(() =>
readBooleanPreference(HIDE_KNOWN_STORAGE_KEY, true),
);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const isWordKnown = (w: VocabularyEntry): boolean => {
return knownWords.has(w.headword) || knownWords.has(w.word);
};
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (w.frequencyRank! < existing.frequencyRank!) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) {
existing.reading = w.reading;
}
if (!existing.partOfSpeech && w.partOfSpeech) {
existing.partOfSpeech = w.partOfSpeech;
}
}
}
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}, [words, knownWords, hideKnown, hasKnownData]);
return buildFrequencyRankRows(words, knownWords, { hideKnown, hideKanaOnly });
}, [words, knownWords, hideKnown, hideKanaOnly]);
if (words.every((w) => w.frequencyRank == null)) {
return (
@@ -81,12 +113,15 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
</span>
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</button>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-2">
{hasKnownData && (
<button
type="button"
aria-pressed={hideKnown}
onClick={() => {
setHideKnown(!hideKnown);
const next = !hideKnown;
setHideKnown(next);
writeBooleanPreference(HIDE_KNOWN_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
@@ -98,12 +133,33 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
Hide Known
</button>
)}
<button
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKanaOnly
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Kana
</button>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
{hideKnown && hasKnownData && !hideKanaOnly
? 'All ranked words are already in Anki!'
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No ranked words match the active filters.'
: 'No words with frequency data.'}
</div>
) : (
<>
@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import type { KanjiEntry } from '../../types/stats';
import { formatNumber } from '../../lib/formatters';
interface KanjiBreakdownProps {
kanji: KanjiEntry[];
@@ -6,34 +8,75 @@ interface KanjiBreakdownProps {
onSelectKanji?: (entry: KanjiEntry) => void;
}
// Heat scale from rare (cool) to very frequent (warm). Catppuccin Macchiato.
const FREQ_TIERS = [
{ min: 0.85, color: 'text-ctp-peach', swatch: 'bg-ctp-peach', label: 'Very frequent' },
{ min: 0.6, color: 'text-ctp-yellow', swatch: 'bg-ctp-yellow', label: 'Frequent' },
{ min: 0.35, color: 'text-ctp-green', swatch: 'bg-ctp-green', label: 'Common' },
{ min: 0.15, color: 'text-ctp-teal', swatch: 'bg-ctp-teal', label: 'Occasional' },
{ min: 0, color: 'text-ctp-subtext0', swatch: 'bg-ctp-subtext0', label: 'Rare' },
] as const;
function tierFor(intensity: number) {
return FREQ_TIERS.find((tier) => intensity >= tier.min) ?? FREQ_TIERS[FREQ_TIERS.length - 1]!;
}
export function KanjiBreakdown({
kanji,
selectedKanjiId = null,
onSelectKanji,
}: KanjiBreakdownProps) {
if (kanji.length === 0) return null;
const { totalOccurrences, maxLogFreq } = useMemo(() => {
let total = 0;
let maxFreq = 1;
for (const entry of kanji) {
total += entry.frequency;
if (entry.frequency > maxFreq) maxFreq = entry.frequency;
}
return { totalOccurrences: total, maxLogFreq: Math.log(maxFreq + 1) };
}, [kanji]);
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
if (kanji.length === 0) return null;
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-ctp-text">
Kanji Encountered
<span className="ml-2 font-normal text-ctp-subtext0">
{formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen
</span>
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-ctp-subtext0">
<span>rare</span>
<div className="flex items-center gap-1">
{[...FREQ_TIERS].reverse().map((tier) => (
<span
key={tier.label}
className={`h-2 w-2 rounded-full ${tier.swatch}`}
title={tier.label}
/>
))}
</div>
<span>frequent</span>
</div>
</div>
<div className="flex flex-wrap gap-1">
{kanji.map((k) => {
const ratio = k.frequency / maxFreq;
const opacity = Math.max(0.3, ratio);
// Log scale keeps the heavily-skewed frequency distribution readable.
const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0;
const tier = tierFor(intensity);
const selected = selectedKanjiId === k.kanjiId;
return (
<button
type="button"
key={k.kanji}
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
selectedKanjiId === k.kanjiId
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
: 'hover:bg-ctp-surface1/80'
className={`cursor-pointer rounded-md px-1.5 py-0.5 text-xl leading-none font-medium transition-colors duration-150 ${tier.color} ${
selected ? 'bg-ctp-surface2 ring-1 ring-ctp-lavender' : 'hover:bg-ctp-surface1'
}`}
style={{ opacity }}
title={`${k.kanji} — seen ${k.frequency}x`}
title={`${k.kanji} — seen ${formatNumber(k.frequency)}×`}
aria-label={`${k.kanji} — seen ${k.frequency} times`}
aria-pressed={selected}
onClick={() => onSelectKanji?.(k)}
>
{k.kanji}
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { KanjiDetailPanel } from './KanjiDetailPanel';
test('KanjiDetailPanel uses the centered detail modal layout', () => {
const markup = renderToStaticMarkup(
createElement(KanjiDetailPanel, { kanjiId: 1, onClose: () => {} }),
);
assert.match(
markup,
/class="[^"]*fixed[^"]*inset-0[^"]*z-40[^"]*flex[^"]*items-center[^"]*justify-center[^"]*p-4/,
);
assert.match(
markup,
/class="[^"]*relative[^"]*flex[^"]*max-h-\[85vh\][^"]*w-full[^"]*max-w-2xl[^"]*flex-col/,
);
assert.doesNotMatch(markup, /class="[^"]*absolute[^"]*right-0[^"]*top-0[^"]*h-full/);
});
@@ -5,6 +5,7 @@ import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../
import type { VocabularyOccurrenceEntry } from '../../types/stats';
const OCCURRENCES_PAGE_SIZE = 50;
const MEDIA_APPEARANCES_LIMIT = 5;
interface KanjiDetailPanelProps {
kanjiId: number | null;
@@ -21,6 +22,22 @@ function formatSegment(ms: number | null): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function highlightKanji(text: string, kanji: string) {
if (!kanji) return text;
const parts = text.split(kanji);
if (parts.length === 1) return text;
return parts.flatMap((part, idx) =>
idx === 0
? [part]
: [
<mark key={idx} className="rounded bg-ctp-teal/20 px-0.5 font-semibold text-ctp-teal">
{kanji}
</mark>,
part,
],
);
}
export function KanjiDetailPanel({
kanjiId,
onClose,
@@ -34,6 +51,7 @@ export function KanjiDetailPanel({
const [occError, setOccError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false);
const [showAllAnime, setShowAllAnime] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
@@ -43,6 +61,7 @@ export function KanjiDetailPanel({
setOccLoadingMore(false);
setOccError(null);
setHasMore(false);
setShowAllAnime(false);
requestIdRef.current++;
}, [kanjiId]);
@@ -87,15 +106,15 @@ export function KanjiDetailPanel({
};
return (
<div className="fixed inset-0 z-40">
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<button
type="button"
aria-label="Close kanji detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<aside className="relative flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
@@ -148,10 +167,13 @@ export function KanjiDetailPanel({
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
Media Appearances
</h3>
<div className="space-y-1.5">
{data.animeAppearances.map((a) => (
{(showAllAnime
? data.animeAppearances
: data.animeAppearances.slice(0, MEDIA_APPEARANCES_LIMIT)
).map((a) => (
<button
key={a.animeId}
type="button"
@@ -168,6 +190,19 @@ export function KanjiDetailPanel({
</button>
))}
</div>
{data.animeAppearances.length > MEDIA_APPEARANCES_LIMIT && (
<button
type="button"
className="mt-2 w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal"
onClick={() => setShowAllAnime((prev) => !prev)}
>
{showAllAnime
? 'Show less'
: `Show ${formatNumber(
data.animeAppearances.length - MEDIA_APPEARANCES_LIMIT,
)} more`}
</button>
)}
</section>
)}
@@ -237,7 +272,7 @@ export function KanjiDetailPanel({
session {occ.sessionId}
</div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occ.text}
{highlightKanji(occ.text, data.detail.kanji)}
</p>
</article>
))}
@@ -36,7 +36,6 @@ export function VocabularyTab({
}: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false);
@@ -116,14 +115,7 @@ export function VocabularyTab({
/>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..."
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<div className="flex items-center justify-end gap-3">
{hasNames && (
<button
type="button"
@@ -178,12 +170,7 @@ export function VocabularyTab({
onSelectWord={handleSelectWord}
/>
<WordList
words={filteredWords}
selectedKey={null}
onSelectWord={handleSelectWord}
search={search}
/>
<WordList words={filteredWords} selectedKey={null} onSelectWord={handleSelectWord} />
<KanjiBreakdown
kanji={kanji}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const WORD_DETAIL_PANEL_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'WordDetailPanel.tsx',
);
test('WordDetailPanel uses the shared stats mining payload builder', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /buildStatsMineCardParams/);
assert.match(source, /getStatsMineCardUnavailableReason/);
assert.match(source, /buildStatsMineCardParams\(\s*occ,\s*data!\.detail\.headword,\s*mode\s*\)/);
});
test('WordDetailPanel shows partial media mining errors instead of silent success', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /getStatsMineCardError/);
assert.match(source, /const responseError = getStatsMineCardError\(result\);/);
});
test('WordDetailPanel uses the wider centered detail modal layout', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /fixed inset-0 z-40 flex items-center justify-center p-4/);
assert.match(source, /relative flex max-h-\[85vh\] w-full max-w-2xl flex-col/);
});
@@ -2,12 +2,18 @@ import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import {
buildStatsMineCardParams,
getStatsMineCardError,
getStatsMineCardUnavailableReason,
} from '../../lib/mining';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyOccurrenceEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers';
const INITIAL_PAGE_SIZE = 5;
const LOAD_MORE_SIZE = 10;
const MEDIA_APPEARANCES_LIMIT = 5;
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
@@ -67,6 +73,7 @@ export function WordDetailPanel({
const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false);
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
const [showAllAnime, setShowAllAnime] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
@@ -77,6 +84,7 @@ export function WordDetailPanel({
setOccError(null);
setHasMore(false);
setMineStatus({});
setShowAllAnime(false);
requestIdRef.current++;
}, [wordId]);
@@ -135,25 +143,18 @@ export function WordDetailPanel({
occ: VocabularyOccurrenceEntry,
mode: 'word' | 'sentence' | 'audio',
) => {
if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) {
const params = buildStatsMineCardParams(occ, data!.detail.headword, mode);
if (!params) {
return;
}
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
const result = await apiClient.mineCard({
sourcePath: occ.sourcePath!,
startMs: occ.segmentStartMs!,
endMs: occ.segmentEndMs!,
sentence: occ.text,
word: data!.detail.headword,
secondaryText: occ.secondaryText,
videoTitle: occ.videoTitle,
mode,
});
if (result.error) {
setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } }));
const result = await apiClient.mineCard(params);
const responseError = getStatsMineCardError(result);
if (responseError) {
setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } }));
} else {
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
const label =
@@ -179,15 +180,15 @@ export function WordDetailPanel({
};
return (
<div className="fixed inset-0 z-40">
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<button
type="button"
aria-label="Close word detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<aside className="relative flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
@@ -275,10 +276,13 @@ export function WordDetailPanel({
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
Media Appearances
</h3>
<div className="space-y-1.5">
{data.animeAppearances.map((a) => (
{(showAllAnime
? data.animeAppearances
: data.animeAppearances.slice(0, MEDIA_APPEARANCES_LIMIT)
).map((a) => (
<button
key={a.animeId}
type="button"
@@ -295,13 +299,26 @@ export function WordDetailPanel({
</button>
))}
</div>
{data.animeAppearances.length > MEDIA_APPEARANCES_LIMIT && (
<button
type="button"
className="mt-2 w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={() => setShowAllAnime((prev) => !prev)}
>
{showAllAnime
? 'Show less'
: `Show ${formatNumber(
data.animeAppearances.length - MEDIA_APPEARANCES_LIMIT,
)} more`}
</button>
)}
</section>
)}
{data.similarWords.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Similar Words
Related Seen Words
</h3>
<div className="flex flex-wrap gap-1.5">
{data.similarWords.map((sw) => (
@@ -368,15 +385,7 @@ export function WordDetailPanel({
· session {occ.sessionId}
</span>
{(() => {
const canMine =
!!occ.sourcePath &&
occ.segmentStartMs != null &&
occ.segmentEndMs != null;
const unavailableReason = canMine
? null
: occ.sourcePath
? 'This line is missing segment timing.'
: 'This source has no local file path.';
const unavailableReason = getStatsMineCardUnavailableReason(occ);
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
@@ -22,7 +22,9 @@ export function posColor(pos: string): string {
export function PosBadge({ pos }: { pos: string }) {
return (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${posColor(pos)}`}
>
{pos.replace(/_/g, ' ')}
</span>
);
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useMemo, useState } from 'react';
import {
buildCoverImageRequestKey,
collectSessionCoverRequests,
getCoverImageKey,
mergeCoverImageData,
type CoverImageMap,
} from '../lib/cover-images';
import { getCoverRetryDelayMs } from '../lib/cover-retry';
import type { SessionSummary } from '../types/stats';
import { getStatsClient } from './useStatsApi';
interface UseCoverImagesOptions {
enabled?: boolean;
}
export function useCoverImages(
sessions: SessionSummary[],
options: UseCoverImagesOptions = {},
): CoverImageMap {
const enabled = options.enabled ?? true;
const requests = useMemo(() => collectSessionCoverRequests(sessions), [sessions]);
const requestKey = useMemo(
() => buildCoverImageRequestKey(requests.animeIds, requests.videoIds, enabled ? 1 : 0),
[requests, enabled],
);
const [images, setImages] = useState<CoverImageMap>({});
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let cachedImages: CoverImageMap = {};
const client = getStatsClient();
async function load(animeIds: number[], videoIds: number[], attempt: number): Promise<void> {
if (animeIds.length === 0 && videoIds.length === 0) {
return;
}
try {
const data = await client.getCoverImages({ animeIds, videoIds });
if (cancelled) return;
cachedImages = mergeCoverImageData(cachedImages, data);
setImages(cachedImages);
} catch {
if (cancelled) return;
}
const missingAnimeIds = animeIds.filter((id) => !cachedImages[getCoverImageKey('anime', id)]);
const missingVideoIds = videoIds.filter((id) => !cachedImages[getCoverImageKey('media', id)]);
if (missingAnimeIds.length === 0 && missingVideoIds.length === 0) {
return;
}
timer = setTimeout(() => {
void load(missingAnimeIds, missingVideoIds, attempt + 1);
}, getCoverRetryDelayMs(attempt));
}
if (!enabled) {
return () => {
cancelled = true;
};
}
if (requests.animeIds.length === 0 && requests.videoIds.length === 0) {
setImages({});
return () => {
cancelled = true;
};
}
void load(requests.animeIds, requests.videoIds, 0);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [requestKey]);
return images;
}
+41
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isExcludedWord,
getExcludedWordsSnapshot,
initializeExcludedWordsStore,
resetExcludedWordsStoreForTests,
@@ -100,6 +101,46 @@ test('setExcludedWords updates the database-backed exclusion list', async () =>
}
});
test('setExcludedWords persists one row per excluded token', async () => {
resetExcludedWordsStoreForTests();
const { values: storage, restore } = installLocalStorage();
const originalFetch = globalThis.fetch;
let seenBody = '';
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
seenBody = String(init?.body ?? '');
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof globalThis.fetch;
try {
const rows = [
{ headword: 'ない', word: 'ない', reading: 'ない' },
{ headword: 'ない', word: '無い', reading: 'ない' },
];
const expected = [{ headword: 'ない', word: 'ない', reading: 'ない' }];
await setExcludedWords(rows);
assert.deepEqual(getExcludedWordsSnapshot(), expected);
assert.equal(seenBody, JSON.stringify({ words: expected }));
assert.equal(storage.get(STORAGE_KEY), JSON.stringify(expected));
} finally {
globalThis.fetch = originalFetch;
restore();
resetExcludedWordsStoreForTests();
}
});
test('exclusion matching covers vocabulary rows with the same visible token', () => {
const excluded = [{ headword: 'ない', word: 'ない', reading: 'ない' }];
assert.equal(isExcludedWord(excluded, { headword: 'ない', word: '無い', reading: 'ない' }), true);
assert.equal(isExcludedWord(excluded, { headword: '無い', word: 'ない', reading: 'ない' }), true);
assert.equal(
isExcludedWord(excluded, { headword: 'なる', word: 'なる', reading: 'なる' }),
false,
);
});
test('setExcludedWords rolls back local state when persistence fails', async () => {
resetExcludedWordsStoreForTests();
const previousRows = [{ headword: '猫', word: '猫', reading: 'ねこ' }];
+58 -20
View File
@@ -6,8 +6,37 @@ export type ExcludedWord = StatsExcludedWord;
const STORAGE_KEY = 'subminer-excluded-words';
function toKey(w: ExcludedWord): string {
return `${w.headword}\0${w.word}\0${w.reading}`;
type ExclusionCandidate = { headword: string; word: string; reading: string };
function normalizedTokenText(value: string): string {
return value.trim();
}
export function getExcludedWordTokenKey(w: ExclusionCandidate): string {
return (
normalizedTokenText(w.headword) || normalizedTokenText(w.word) || normalizedTokenText(w.reading)
);
}
function getExcludedWordAliasKeys(w: ExclusionCandidate): string[] {
const aliases = [normalizedTokenText(w.headword), normalizedTokenText(w.word)].filter(Boolean);
const unique = new Set(aliases);
if (unique.size === 0) unique.add(getExcludedWordTokenKey(w));
return [...unique];
}
export function dedupeExcludedWords(words: ExcludedWord[]): ExcludedWord[] {
const byToken = new Map<string, ExcludedWord>();
for (const word of words) {
const key = getExcludedWordTokenKey(word);
if (!byToken.has(key)) byToken.set(key, word);
}
return [...byToken.values()];
}
export function isExcludedWord(excluded: ExcludedWord[], w: ExclusionCandidate): boolean {
const excludedKeys = new Set(excluded.flatMap(getExcludedWordAliasKeys));
return getExcludedWordAliasKeys(w).some((key) => excludedKeys.has(key));
}
let cached: ExcludedWord[] | null = null;
@@ -22,13 +51,15 @@ function readLocalStorage(): ExcludedWord[] {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(row): row is ExcludedWord =>
row !== null &&
typeof row === 'object' &&
typeof (row as ExcludedWord).headword === 'string' &&
typeof (row as ExcludedWord).word === 'string' &&
typeof (row as ExcludedWord).reading === 'string',
return dedupeExcludedWords(
parsed.filter(
(row): row is ExcludedWord =>
row !== null &&
typeof row === 'object' &&
typeof (row as ExcludedWord).headword === 'string' &&
typeof (row as ExcludedWord).word === 'string' &&
typeof (row as ExcludedWord).reading === 'string',
),
);
} catch {
return [];
@@ -48,14 +79,15 @@ function load(): ExcludedWord[] {
function getKeySet(): Set<string> {
if (cachedKeys) return cachedKeys;
cachedKeys = new Set(load().map(toKey));
cachedKeys = new Set(load().flatMap(getExcludedWordAliasKeys));
return cachedKeys;
}
function applyWords(words: ExcludedWord[]): void {
cached = words;
cachedKeys = new Set(words.map(toKey));
writeLocalStorage(words);
const normalized = dedupeExcludedWords(words);
cached = normalized;
cachedKeys = new Set(normalized.flatMap(getExcludedWordAliasKeys));
writeLocalStorage(normalized);
for (const fn of listeners) fn();
}
@@ -67,10 +99,11 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise<void> {
const previousWords = [...load()];
const previousRevision = revision;
const writeRevision = previousRevision + 1;
const normalized = dedupeExcludedWords(words);
revision = writeRevision;
applyWords(words);
applyWords(normalized);
try {
await apiClient.setExcludedWords(words);
await apiClient.setExcludedWords(normalized);
} catch (error) {
if (revision === writeRevision) {
revision = previousRevision;
@@ -137,22 +170,27 @@ export function useExcludedWords() {
}, []);
const isExcluded = useCallback(
(w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)),
(w: ExclusionCandidate) => getExcludedWordAliasKeys(w).some((key) => getKeySet().has(key)),
[excluded],
);
const toggleExclusion = useCallback((w: ExcludedWord) => {
const key = toKey(w);
const current = load();
if (getKeySet().has(key)) {
void setExcludedWords(current.filter((e) => toKey(e) !== key));
const candidateKeys = new Set(getExcludedWordAliasKeys(w));
const existing = current.find((e) =>
getExcludedWordAliasKeys(e).some((key) => candidateKeys.has(key)),
);
if (existing) {
const key = getExcludedWordTokenKey(existing);
void setExcludedWords(current.filter((e) => getExcludedWordTokenKey(e) !== key));
} else {
void setExcludedWords([...current, w]);
}
}, []);
const removeExclusion = useCallback((w: ExcludedWord) => {
void setExcludedWords(load().filter((e) => toKey(e) !== toKey(w)));
const key = getExcludedWordTokenKey(w);
void setExcludedWords(load().filter((e) => getExcludedWordTokenKey(e) !== key));
}, []);
const clearAll = useCallback(() => {
+65
View File
@@ -32,6 +32,43 @@ test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
animeId: number,
retryToken?: number,
) => string;
assert.equal(
getAnimeCoverUrl(42, 3),
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
);
});
test('getCoverImages batches anime and media cover requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
let seenBody = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
seenBody = String(init?.body ?? '');
return new Response(JSON.stringify({ anime: {}, media: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
assert.equal(seenMethod, 'POST');
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
@@ -51,6 +88,34 @@ test('deleteSession sends a DELETE request to the session endpoint', async () =>
}
});
test('searchSentences encodes realtime sentence search requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.searchSentences('猫 食べる', 25);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=true`,
);
await apiClient.searchSentences('猫 食べる', 25, false);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=false`,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
+40 -11
View File
@@ -6,6 +6,7 @@ import type {
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
SentenceSearchResult,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
@@ -23,7 +24,10 @@ import type {
EpisodeDetailData,
StatsAnkiNoteInfo,
StatsExcludedWord,
StatsCoverImagesData,
} from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -65,6 +69,16 @@ async function fetchJson<T>(path: string): Promise<T> {
return res.json() as Promise<T>;
}
function uniquePositiveIds(ids: number[]): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
@@ -103,6 +117,14 @@ export const apiClient = {
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
fetchJson<SentenceSearchResult[]>(
`/api/stats/sentences/search?${new URLSearchParams({
q: query,
limit: String(limit),
headword: String(searchByHeadword),
}).toString()}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
@@ -116,7 +138,22 @@ export const apiClient = {
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
getCoverImages: async (params: {
animeIds: number[];
videoIds: number[];
}): Promise<StatsCoverImagesData> => {
const res = await fetchResponse('/api/stats/covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
animeIds: uniquePositiveIds(params.animeIds),
videoIds: uniquePositiveIds(params.videoIds),
}),
});
return res.json() as Promise<StatsCoverImagesData>;
},
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
@@ -175,6 +212,7 @@ export const apiClient = {
episodes: number | null;
season: string | null;
seasonYear: number | null;
description: string | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
@@ -197,16 +235,7 @@ export const apiClient = {
body: JSON.stringify(info),
});
},
mineCard: async (params: {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: 'word' | 'sentence' | 'audio';
}): Promise<{ noteId?: number; error?: string; errors?: string[] }> => {
mineCard: async (params: StatsMineCardParams): Promise<StatsMineCardResponse> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+2
View File
@@ -13,6 +13,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/search\/SearchTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
@@ -23,6 +24,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
source,
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
);
assert.doesNotMatch(source, /import \{ SearchTab \} from '\.\/components\/search\/SearchTab';/);
assert.doesNotMatch(
source,
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildCoverImageRequestKey,
collectSessionCoverRequests,
getCoverImageKey,
} from './cover-images';
import type { SessionSummary } from '../types/stats';
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
const { sessionId, ...rest } = overrides;
return {
sessionId,
canonicalTitle: null,
videoId: null,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 0,
tokensSeen: 0,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
...rest,
};
}
test('collectSessionCoverRequests dedupes anime ids and only requests media for ungrouped sessions', () => {
const requests = collectSessionCoverRequests([
makeSession({ sessionId: 1, animeId: 10, videoId: 100 }),
makeSession({ sessionId: 2, animeId: 10, videoId: 101 }),
makeSession({ sessionId: 3, animeId: null, videoId: 200 }),
makeSession({ sessionId: 4, animeId: null, videoId: 200 }),
]);
assert.deepEqual(requests, { animeIds: [10], videoIds: [200] });
});
test('getCoverImageKey separates anime and media ids', () => {
assert.equal(getCoverImageKey('anime', 1), 'anime:1');
assert.equal(getCoverImageKey('media', 1), 'media:1');
});
test('buildCoverImageRequestKey changes when callers force a cover refresh', () => {
assert.notEqual(buildCoverImageRequestKey([10], [], 0), buildCoverImageRequestKey([10], [], 1));
});
+80
View File
@@ -0,0 +1,80 @@
import type { SessionSummary, StatsCoverImagesData } from '../types/stats';
export type CoverImageKind = 'anime' | 'media';
export type CoverImageMap = Record<string, string>;
export interface CoverImageRequest {
animeIds: number[];
videoIds: number[];
}
function normalizePositiveIds(ids: Iterable<number | null | undefined>): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (typeof id === 'number' && Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export function getCoverImageKey(kind: CoverImageKind, id: number): string {
return `${kind}:${id}`;
}
export function buildCoverImageRequestKey(
animeIds: number[],
videoIds: number[],
refreshToken = 0,
): string {
return `a:${animeIds.join(',')}|m:${videoIds.join(',')}|r:${refreshToken}`;
}
export function collectSessionCoverRequests(
sessions: Pick<SessionSummary, 'animeId' | 'videoId'>[],
): CoverImageRequest {
const animeIds: number[] = [];
const videoIds: number[] = [];
for (const session of sessions) {
if (session.animeId != null) {
animeIds.push(session.animeId);
} else if (session.videoId != null) {
videoIds.push(session.videoId);
}
}
return {
animeIds: normalizePositiveIds(animeIds),
videoIds: normalizePositiveIds(videoIds),
};
}
export function mergeCoverImageData(
previous: CoverImageMap,
data: StatsCoverImagesData,
): CoverImageMap {
const next = { ...previous };
for (const [id, image] of Object.entries(data.anime)) {
if (image?.dataUrl) {
next[getCoverImageKey('anime', Number(id))] = image.dataUrl;
}
}
for (const [id, image] of Object.entries(data.media)) {
if (image?.dataUrl) {
next[getCoverImageKey('media', Number(id))] = image.dataUrl;
}
}
return next;
}
export function getCoverImageSrc(
images: CoverImageMap,
kind: CoverImageKind,
id: number | null,
): string | null {
return id == null ? null : (images[getCoverImageKey(kind, id)] ?? null);
}
+23
View File
@@ -0,0 +1,23 @@
const COVER_RETRY_PARAM = 'coverRetry';
export function appendCoverRetryToken(src: string, retryToken = 0): string {
if (!Number.isFinite(retryToken) || retryToken <= 0) return src;
const normalizedToken = String(Math.trunc(retryToken));
try {
// Dummy base lets URL parse relative API paths; it is never returned as a real host.
const url = new URL(src, 'http://subminer.local');
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
if (src.startsWith('/')) {
return `${url.pathname}${url.search}${url.hash}`;
}
return url.toString();
} catch {
const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}${COVER_RETRY_PARAM}=${encodeURIComponent(normalizedToken)}`;
}
}
export function getCoverRetryDelayMs(retryToken: number): number {
return Math.min(30_000, 2_000 * 2 ** Math.min(Math.max(retryToken, 0), 4));
}
+6
View File
@@ -0,0 +1,6 @@
const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}\u30fc\u309d\u309e\u30fd\u30fe]+$/u;
export function isKanaOnlyTokenText(text: string): boolean {
const trimmed = text.trim();
return trimmed.length > 0 && KANA_ONLY_TEXT.test(trimmed);
}
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_LIBRARY_CARD_SIZE,
LIBRARY_CARD_SIZE_STORAGE_KEY,
getLibraryCardSizeStorage,
readLibraryCardSizePreference,
writeLibraryCardSizePreference,
} from './library-card-size';
function createStorage(initial: Record<string, string | null> = {}): Storage {
const values = new Map(
Object.entries(initial).filter((entry): entry is [string, string] => {
return entry[1] !== null;
}),
);
return {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
};
}
test('readLibraryCardSizePreference returns saved valid sizes', () => {
const storage = createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'lg' });
assert.equal(readLibraryCardSizePreference(storage), 'lg');
});
test('readLibraryCardSizePreference falls back for missing or invalid saved sizes', () => {
assert.equal(readLibraryCardSizePreference(createStorage()), DEFAULT_LIBRARY_CARD_SIZE);
assert.equal(
readLibraryCardSizePreference(createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'xl' })),
DEFAULT_LIBRARY_CARD_SIZE,
);
});
test('library card size preference helpers ignore storage failures', () => {
const storage = {
getItem() {
throw new Error('blocked');
},
setItem() {
throw new Error('blocked');
},
} as unknown as Storage;
assert.equal(readLibraryCardSizePreference(storage), DEFAULT_LIBRARY_CARD_SIZE);
assert.doesNotThrow(() => writeLibraryCardSizePreference(storage, 'sm'));
});
test('getLibraryCardSizeStorage returns null when localStorage access is blocked', () => {
const source = {
get localStorage(): Storage {
throw new Error('blocked');
},
};
assert.equal(getLibraryCardSizeStorage(source), null);
});
+36
View File
@@ -0,0 +1,36 @@
export type LibraryCardSize = 'sm' | 'md' | 'lg';
export const DEFAULT_LIBRARY_CARD_SIZE: LibraryCardSize = 'md';
export const LIBRARY_CARD_SIZE_STORAGE_KEY = 'subminer.stats.library.cardSize';
export function getLibraryCardSizeStorage(
source: { localStorage: Storage } | null | undefined,
): Storage | null {
try {
return source?.localStorage ?? null;
} catch {
return null;
}
}
export function readLibraryCardSizePreference(
storage: Storage | null | undefined,
): LibraryCardSize {
try {
const value = storage?.getItem(LIBRARY_CARD_SIZE_STORAGE_KEY);
return value === 'sm' || value === 'md' || value === 'lg' ? value : DEFAULT_LIBRARY_CARD_SIZE;
} catch {
return DEFAULT_LIBRARY_CARD_SIZE;
}
}
export function writeLibraryCardSizePreference(
storage: Storage | null | undefined,
size: LibraryCardSize,
): void {
try {
storage?.setItem(LIBRARY_CARD_SIZE_STORAGE_KEY, size);
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
@@ -5,6 +5,7 @@ import type { MediaLibraryItem } from '../types/stats';
import {
groupMediaLibraryItems,
resolveMediaArtworkUrl,
resolveMediaCoverApiUrl,
summarizeMediaLibraryGroups,
} from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
@@ -172,6 +173,13 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
assert.equal(
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
);
});
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
+3 -2
View File
@@ -1,4 +1,5 @@
import { BASE_URL } from './api-client';
import { appendCoverRetryToken } from './cover-retry';
import type { MediaLibraryItem } from '../types/stats';
export interface MediaLibraryGroup {
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
return normalized.length > 0 ? normalized : null;
}
export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
}
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsMineCardParams,
getStatsMineCardError,
getStatsMineCardUnavailableReason,
} from './mining';
import type { SentenceSearchResult } from '../types/stats';
function makeResult(overrides: Partial<SentenceSearchResult> = {}): SentenceSearchResult {
return {
animeId: null,
animeTitle: 'Little Witch Academia',
videoId: 4,
videoTitle: 'Episode 4',
sourcePath: '/tmp/lwa.mkv',
secondaryText: 'Magic is gone',
sessionId: 7,
lineIndex: 12,
segmentStartMs: 5_000,
segmentEndMs: 6_000,
text: '魔法がなくなった',
...overrides,
};
}
test('buildStatsMineCardParams maps sentence result context to the shared mining payload', () => {
assert.deepEqual(buildStatsMineCardParams(makeResult(), '魔法', 'sentence'), {
sourcePath: '/tmp/lwa.mkv',
startMs: 5_000,
endMs: 6_000,
sentence: '魔法がなくなった',
word: '魔法',
secondaryText: 'Magic is gone',
videoTitle: 'Episode 4',
mode: 'sentence',
});
});
test('buildStatsMineCardParams returns null when media context is incomplete', () => {
assert.equal(
buildStatsMineCardParams(makeResult({ sourcePath: null }), '魔法', 'sentence'),
null,
);
assert.equal(
buildStatsMineCardParams(makeResult({ segmentStartMs: null }), '魔法', 'sentence'),
null,
);
assert.equal(
buildStatsMineCardParams(makeResult({ segmentEndMs: null }), '魔法', 'sentence'),
null,
);
});
test('buildStatsMineCardParams returns null when stored timing has no positive duration', () => {
assert.equal(
buildStatsMineCardParams(
makeResult({ segmentStartMs: 5_000, segmentEndMs: 4_900 }),
'魔法',
'sentence',
),
null,
);
assert.equal(
getStatsMineCardUnavailableReason(makeResult({ segmentStartMs: 5_000, segmentEndMs: 5_000 })),
'This line has invalid segment timing.',
);
});
test('getStatsMineCardError surfaces partial media failures', () => {
assert.equal(
getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }),
'audio: ffmpeg failed',
);
assert.equal(getStatsMineCardError({ error: 'File not found' }), 'File not found');
assert.equal(getStatsMineCardError({ noteId: 1 }), null);
});
+68
View File
@@ -0,0 +1,68 @@
import type { SentenceSearchResult } from '../types/stats';
export type StatsMineMode = 'word' | 'sentence' | 'audio';
export interface StatsMineCardParams {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: StatsMineMode;
}
export interface StatsMineCardResponse {
noteId?: number;
error?: string;
errors?: string[];
}
export function getStatsMineCardUnavailableReason(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
): string | null {
if (!result.sourcePath) {
return 'This source has no local file path.';
}
if (result.segmentStartMs == null || result.segmentEndMs == null) {
return 'This line is missing segment timing.';
}
if (
!Number.isFinite(result.segmentStartMs) ||
!Number.isFinite(result.segmentEndMs) ||
result.segmentEndMs <= result.segmentStartMs
) {
return 'This line has invalid segment timing.';
}
return null;
}
export function buildStatsMineCardParams(
result: Pick<
SentenceSearchResult,
'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text' | 'secondaryText' | 'videoTitle'
>,
word: string,
mode: StatsMineMode,
): StatsMineCardParams | null {
if (getStatsMineCardUnavailableReason(result)) {
return null;
}
return {
sourcePath: result.sourcePath!,
startMs: result.segmentStartMs!,
endMs: result.segmentEndMs!,
sentence: result.text,
word,
secondaryText: result.secondaryText,
videoTitle: result.videoTitle,
mode,
};
}
export function getStatsMineCardError(response: StatsMineCardResponse): string | null {
if (response.error) return response.error;
return response.errors?.[0] ?? null;
}
+26
View File
@@ -0,0 +1,26 @@
function getPreferenceStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
export function readBooleanPreference(key: string, fallback: boolean): boolean {
try {
const value = getPreferenceStorage()?.getItem(key);
if (value === 'true') return true;
if (value === 'false') return false;
return fallback;
} catch {
return fallback;
}
}
export function writeBooleanPreference(key: string, value: boolean): void {
try {
getPreferenceStorage()?.setItem(key, String(value));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
+12 -11
View File
@@ -1,51 +1,52 @@
import { describe, it, expect } from 'vitest';
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { fullReading } from './reading-utils';
describe('fullReading', () => {
it('prefixes leading hiragana from headword', () => {
// お前 with reading まえ → おまえ
expect(fullReading('お前', 'まえ')).toBe('おまえ');
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
});
it('handles katakana stored readings', () => {
// お前 with katakana reading マエ → おまえ
expect(fullReading('お前', 'マエ')).toBe('おまえ');
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
});
it('returns stored reading when it already includes leading kana', () => {
// Reading already correct
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
});
it('handles trailing hiragana', () => {
// 隠す with reading かくす — す is trailing hiragana
expect(fullReading('隠す', 'かくす')).toBe('かくす');
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
});
it('handles pure kanji headwords', () => {
expect(fullReading('様', 'さま')).toBe('さま');
assert.equal(fullReading('様', 'さま'), 'さま');
});
it('returns empty for empty reading', () => {
expect(fullReading('前', '')).toBe('');
assert.equal(fullReading('前', ''), '');
});
it('returns empty for empty headword', () => {
expect(fullReading('', 'まえ')).toBe('まえ');
assert.equal(fullReading('', 'まえ'), 'まえ');
});
it('handles all-kana headword', () => {
// Headword is already all hiragana
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
});
it('handles mixed leading and trailing kana', () => {
// お気に入り: お=leading, に入り=trailing around 気
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
});
it('handles katakana in headword', () => {
// カズマ様 — leading katakana + kanji
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
});
});
+8 -4
View File
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
const chars = [...headword];
let i = 0;
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
leadingKana.push(katakanaToHiragana(chars[i]));
while (i < chars.length) {
const ch = chars[i]!;
if (!isHiragana(ch) && !isKatakana(ch)) break;
leadingKana.push(katakanaToHiragana(ch));
i++;
}
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
}
let j = chars.length - 1;
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
trailingKana.unshift(katakanaToHiragana(chars[j]));
while (j > i) {
const ch = chars[j]!;
if (!isHiragana(ch) && !isKatakana(ch)) break;
trailingKana.unshift(katakanaToHiragana(ch));
j--;
}
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import {
findExactSentenceMatches,
getSentenceSearchMineAvailability,
renderSentenceWithMatches,
} from './sentence-search';
import type { SentenceSearchResult } from '../types/stats';
function makeResult(over: Partial<SentenceSearchResult>): SentenceSearchResult {
return {
animeId: null,
animeTitle: null,
videoId: 1,
videoTitle: 'Episode 1',
sourcePath: '/tmp/video.mkv',
secondaryText: null,
sessionId: 10,
lineIndex: 3,
segmentStartMs: 1000,
segmentEndMs: 2500,
text: '猫が猫を見た',
...over,
};
}
test('findExactSentenceMatches returns every exact searched-word range', () => {
assert.deepEqual(findExactSentenceMatches('猫が猫を見た', '猫'), [
{ start: 0, end: 1 },
{ start: 2, end: 3 },
]);
});
test('findExactSentenceMatches keeps source-text ranges under case folding', () => {
assert.deepEqual(findExactSentenceMatches('İstanbul', 'İ'), [{ start: 0, end: 1 }]);
});
test('getSentenceSearchMineAvailability gates word and audio mining on exact sentence match', () => {
const result = makeResult({});
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
canMineSentence: true,
canMineWordAudio: true,
exactMatch: true,
unavailableReason: null,
});
assert.deepEqual(getSentenceSearchMineAvailability(result, '犬'), {
canMineSentence: true,
canMineWordAudio: false,
exactMatch: false,
unavailableReason: null,
});
});
test('getSentenceSearchMineAvailability disables every mining mode without source timing', () => {
const result = makeResult({ sourcePath: null, segmentEndMs: null });
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
canMineSentence: false,
canMineWordAudio: false,
exactMatch: true,
unavailableReason: 'This source has no local file path.',
});
});
test('getSentenceSearchMineAvailability disables every mining mode with invalid source timing', () => {
const result = makeResult({ segmentStartMs: 2500, segmentEndMs: 2400 });
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
canMineSentence: false,
canMineWordAudio: false,
exactMatch: true,
unavailableReason: 'This line has invalid segment timing.',
});
});
test('renderSentenceWithMatches highlights exact searched-word matches', () => {
const markup = renderToStaticMarkup(<>{renderSentenceWithMatches('猫が寝る', '猫')}</>);
assert.match(markup, /<mark/);
assert.match(markup, />猫<\/mark>/);
});
+112
View File
@@ -0,0 +1,112 @@
import { Fragment, type ReactNode } from 'react';
import type { SentenceSearchResult } from '../types/stats';
import { getStatsMineCardUnavailableReason } from './mining';
export interface SentenceMatchRange {
start: number;
end: number;
}
export interface SentenceSearchMineAvailability {
canMineSentence: boolean;
canMineWordAudio: boolean;
exactMatch: boolean;
unavailableReason: string | null;
}
function normalizedSearchWord(query: string): string {
return query.trim();
}
function buildFoldedSearchIndex(text: string): {
text: string;
sourceStartByIndex: number[];
sourceEndByIndex: number[];
} {
let foldedText = '';
const sourceStartByIndex: number[] = [];
const sourceEndByIndex: number[] = [];
for (let sourceStart = 0; sourceStart < text.length; ) {
const codePoint = text.codePointAt(sourceStart);
if (codePoint == null) break;
const char = String.fromCodePoint(codePoint);
const sourceEnd = sourceStart + char.length;
const foldedChar = char.toLocaleLowerCase();
for (let index = 0; index < foldedChar.length; index++) {
sourceStartByIndex.push(sourceStart);
sourceEndByIndex.push(sourceEnd);
}
foldedText += foldedChar;
sourceStart = sourceEnd;
}
return { text: foldedText, sourceStartByIndex, sourceEndByIndex };
}
export function findExactSentenceMatches(text: string, query: string): SentenceMatchRange[] {
const needle = normalizedSearchWord(query);
if (!needle) return [];
const ranges: SentenceMatchRange[] = [];
const haystack = buildFoldedSearchIndex(text);
const normalizedNeedle = needle.toLocaleLowerCase();
let searchFrom = 0;
while (searchFrom < haystack.text.length) {
const index = haystack.text.indexOf(normalizedNeedle, searchFrom);
if (index < 0) break;
const endIndex = index + normalizedNeedle.length - 1;
ranges.push({
start: haystack.sourceStartByIndex[index] ?? index,
end: haystack.sourceEndByIndex[endIndex] ?? index + normalizedNeedle.length,
});
searchFrom = index + normalizedNeedle.length;
}
return ranges;
}
export function getSentenceSearchMineAvailability(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text'>,
query: string,
): SentenceSearchMineAvailability {
const exactMatch = findExactSentenceMatches(result.text, query).length > 0;
const unavailableReason = getStatsMineCardUnavailableReason(result);
return {
canMineSentence: unavailableReason === null,
canMineWordAudio: unavailableReason === null && exactMatch,
exactMatch,
unavailableReason,
};
}
export function renderSentenceWithMatches(text: string, query: string): ReactNode {
const ranges = findExactSentenceMatches(text, query);
if (ranges.length === 0) return text;
const parts: ReactNode[] = [];
let cursor = 0;
ranges.forEach((range, index) => {
if (range.start > cursor) {
parts.push(<Fragment key={`text-${cursor}`}>{text.slice(cursor, range.start)}</Fragment>);
}
parts.push(
<mark
key={`${range.start}-${index}`}
className="rounded bg-ctp-yellow/15 px-0.5 text-ctp-yellow underline decoration-ctp-yellow/60 underline-offset-2"
>
{text.slice(range.start, range.end)}
</mark>,
);
cursor = range.end;
});
if (cursor < text.length) {
parts.push(<Fragment key={`text-${cursor}`}>{text.slice(cursor)}</Fragment>);
}
return parts;
}
+3 -2
View File
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
import { groupSessionsByVideo } from './session-grouping';
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
const { sessionId, ...rest } = overrides;
return {
sessionId: overrides.sessionId,
sessionId,
canonicalTitle: null,
videoId: null,
animeId: null,
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
...overrides,
...rest,
};
}
@@ -10,6 +10,7 @@ test('TabBar renders Library instead of Anime for the media library tab', () =>
assert.doesNotMatch(markup, />Anime</);
assert.match(markup, />Overview</);
assert.match(markup, />Library</);
assert.match(markup, />Search</);
});
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
-1
View File
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
episodeCount: 3,
lastWatchedMs: 0,
}}
avgSessionMs={20_000}
knownWordsSummary={null}
/>,
);
+1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
+26
View File
@@ -82,6 +82,16 @@ export interface StatsExcludedWord {
reading: string;
}
export interface StatsCoverImage {
contentType: string;
dataUrl: string;
}
export interface StatsCoverImagesData {
anime: Record<number, StatsCoverImage | null>;
media: Record<number, StatsCoverImage | null>;
}
export interface KanjiEntry {
kanjiId: number;
kanji: string;
@@ -105,6 +115,20 @@ export interface VocabularyOccurrenceEntry {
occurrenceCount: number;
}
export interface SentenceSearchResult {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
}
export interface OverviewData {
sessions: SessionSummary[];
rollups: DailyRollup[];
@@ -325,6 +349,8 @@ export interface TrendsDashboardData {
};
ratios: {
lookupsPerHundred: TrendChartPoint[];
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
};
librarySummary: LibrarySummaryRow[];
animeCumulative: {