mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
feat(stats): speed up session maintenance and improve stats UI (#111)
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}…
|
||||
</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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user