feat(stats): add media-detail navigation from Sessions rows; fix(tokenizer): exclude そうだ auxiliary-stem from annotations

- Added hover-revealed ↗ button on SessionRow that navigates to the
  anime media-detail view for the session's videoId
- Added `sessions` origin type to MediaDetailOrigin and
  openSessionsMediaDetail() / closeMediaDetail() handling so the
  back button returns correctly to the Sessions tab ("Back to Sessions")
- Wired onNavigateToMediaDetail down through SessionsTab → SessionRow
- Excluded tokens with MeCab POS3 = 助動詞語幹 (e.g. そうだ grammar tails)
  from subtitle annotation metadata so frequency, JLPT, and N+1 styling
  no longer apply to grammar-tail tokens
- Added annotation-stage unit test and end-to-end tokenizeSubtitle test
  for the そうだ exclusion path
- Updated docs-site changelog, immersion-tracking, and
  subtitle-annotations pages to reflect both changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:42:53 -07:00
parent 59fa3b427d
commit 0ea1746123
7 changed files with 72 additions and 5 deletions

View File

@@ -10,6 +10,7 @@ import {
navigateToSession as navigateToSessionState,
openAnimeEpisodeDetail,
openOverviewMediaDetail,
openSessionsMediaDetail,
switchTab,
} from './lib/stats-navigation';
@@ -110,6 +111,10 @@ export function App() {
[],
);
const navigateToSessionsMediaDetail = useCallback((videoId: number) => {
setViewState((prev) => openSessionsMediaDetail(prev, videoId));
}, []);
const openWordDetail = useCallback((wordId: number) => {
setGlobalWordId(wordId);
}, []);
@@ -155,7 +160,14 @@ export function App() {
}
onBack={() => setViewState((prev) => closeMediaDetail(prev))}
backLabel={
mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library'
mediaDetail.origin.type === 'overview'
? 'Back to Overview'
: mediaDetail.origin.type === 'sessions'
? 'Back to Sessions'
: 'Back to Library'
}
onNavigateToAnime={
mediaDetail.origin.type === 'anime' ? undefined : navigateToAnime
}
/>
</Suspense>
@@ -242,6 +254,7 @@ export function App() {
onClearInitialSession={() =>
setViewState((prev) => ({ ...prev, focusedSessionId: null }))
}
onNavigateToMediaDetail={navigateToSessionsMediaDetail}
/>
</Suspense>
</section>

View File

@@ -11,6 +11,7 @@ interface SessionRowProps {
onToggle: () => void;
onDelete: () => void;
deleteDisabled?: boolean;
onNavigateToMediaDetail?: (videoId: number) => void;
}
function CoverThumbnail({
@@ -56,6 +57,7 @@ export function SessionRow({
onToggle,
onDelete,
deleteDisabled = false,
onNavigateToMediaDetail,
}: SessionRowProps) {
const displayWordCount = getSessionDisplayWordCount(session);
const knownWordsSeen = session.knownWordsSeen;
@@ -109,6 +111,20 @@ export function SessionRow({
{'\u25B8'}
</div>
</button>
{onNavigateToMediaDetail != null && session.videoId != null ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigateToMediaDetail(session.videoId!);
}}
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
title="View anime overview"
>
{'\u2197'}
</button>
) : null}
<button
type="button"
onClick={onDelete}

View File

@@ -26,9 +26,14 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
interface SessionsTabProps {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
onNavigateToMediaDetail?: (videoId: number) => void;
}
export function SessionsTab({ initialSessionId, onClearInitialSession }: SessionsTabProps = {}) {
export function SessionsTab({
initialSessionId,
onClearInitialSession,
onNavigateToMediaDetail,
}: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState('');
@@ -125,6 +130,7 @@ export function SessionsTab({ initialSessionId, onClearInitialSession }: Session
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>

View File

@@ -1,7 +1,10 @@
import type { SessionSummary } from '../types/stats';
import type { TabId } from '../components/layout/TabBar';
export type MediaDetailOrigin = { type: 'anime'; animeId: number } | { type: 'overview' };
export type MediaDetailOrigin =
| { type: 'anime'; animeId: number }
| { type: 'overview' }
| { type: 'sessions' };
export interface MediaDetailState {
videoId: number;
@@ -92,6 +95,24 @@ export function openOverviewMediaDetail(
};
}
export function openSessionsMediaDetail(
state: StatsViewState,
videoId: number,
): StatsViewState {
return {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId,
initialSessionId: null,
origin: {
type: 'sessions',
},
},
};
}
export function closeMediaDetail(state: StatsViewState): StatsViewState {
if (!state.mediaDetail) {
return state;
@@ -106,6 +127,15 @@ export function closeMediaDetail(state: StatsViewState): StatsViewState {
};
}
if (state.mediaDetail.origin.type === 'sessions') {
return {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
};
}
return {
activeTab: 'anime',
selectedAnimeId: state.mediaDetail.origin.animeId,