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

@@ -6,6 +6,8 @@
- Improved stats accuracy and scale handling with Yomitan token counts, full session timelines, known-word timeline fixes, cross-media vocabulary fixes, and clearer session charts. - Improved stats accuracy and scale handling with Yomitan token counts, full session timelines, known-word timeline fixes, cross-media vocabulary fixes, and clearer session charts.
- Improved overlay/runtime stability with quieter macOS fullscreen recovery, reduced repeated loading OSD popups, and better frequency/noise handling for subtitle annotations. - Improved overlay/runtime stability with quieter macOS fullscreen recovery, reduced repeated loading OSD popups, and better frequency/noise handling for subtitle annotations.
- Added launcher mpv-args passthrough plus Linux plugin wrapper-name fallback for packaged installs. - Added launcher mpv-args passthrough plus Linux plugin wrapper-name fallback for packaged installs.
- Added a hover-revealed ↗ button on Sessions tab rows to navigate directly to the anime media-detail view, with correct "Back to Sessions" back-navigation.
- Excluded auxiliary-stem `そうだ` grammar tails (MeCab POS3 `助動詞語幹`) from subtitle annotation metadata so frequency, JLPT, and N+1 styling no longer bleed onto grammar-tail tokens.
## v0.6.5 (2026-03-15) ## v0.6.5 (2026-03-15)
- Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state. - Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.

View File

@@ -52,7 +52,7 @@ Watch time, sessions, words seen, and per-anime progress/pattern charts with con
#### Sessions #### Sessions
Expandable session history with new-word activity, cumulative totals, and pause/seek/card markers. Expandable session history with new-word activity, cumulative totals, and pause/seek/card markers. Each session row exposes a hover-revealed ↗ button that navigates to the anime media-detail view for that session; pressing the back button there returns to the Sessions tab.
![Stats Sessions](/screenshots/stats-sessions.png) ![Stats Sessions](/screenshots/stats-sessions.png)

View File

@@ -4,7 +4,7 @@ SubMiner annotates subtitle tokens in real time as they appear in the overlay. F
All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently — you can enable any combination. All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently — you can enable any combination.
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling. Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
## N+1 Word Highlighting ## N+1 Word Highlighting

View File

@@ -10,6 +10,7 @@ import {
navigateToSession as navigateToSessionState, navigateToSession as navigateToSessionState,
openAnimeEpisodeDetail, openAnimeEpisodeDetail,
openOverviewMediaDetail, openOverviewMediaDetail,
openSessionsMediaDetail,
switchTab, switchTab,
} from './lib/stats-navigation'; } 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) => { const openWordDetail = useCallback((wordId: number) => {
setGlobalWordId(wordId); setGlobalWordId(wordId);
}, []); }, []);
@@ -155,7 +160,14 @@ export function App() {
} }
onBack={() => setViewState((prev) => closeMediaDetail(prev))} onBack={() => setViewState((prev) => closeMediaDetail(prev))}
backLabel={ 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> </Suspense>
@@ -242,6 +254,7 @@ export function App() {
onClearInitialSession={() => onClearInitialSession={() =>
setViewState((prev) => ({ ...prev, focusedSessionId: null })) setViewState((prev) => ({ ...prev, focusedSessionId: null }))
} }
onNavigateToMediaDetail={navigateToSessionsMediaDetail}
/> />
</Suspense> </Suspense>
</section> </section>

View File

@@ -11,6 +11,7 @@ interface SessionRowProps {
onToggle: () => void; onToggle: () => void;
onDelete: () => void; onDelete: () => void;
deleteDisabled?: boolean; deleteDisabled?: boolean;
onNavigateToMediaDetail?: (videoId: number) => void;
} }
function CoverThumbnail({ function CoverThumbnail({
@@ -56,6 +57,7 @@ export function SessionRow({
onToggle, onToggle,
onDelete, onDelete,
deleteDisabled = false, deleteDisabled = false,
onNavigateToMediaDetail,
}: SessionRowProps) { }: SessionRowProps) {
const displayWordCount = getSessionDisplayWordCount(session); const displayWordCount = getSessionDisplayWordCount(session);
const knownWordsSeen = session.knownWordsSeen; const knownWordsSeen = session.knownWordsSeen;
@@ -109,6 +111,20 @@ export function SessionRow({
{'\u25B8'} {'\u25B8'}
</div> </div>
</button> </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 <button
type="button" type="button"
onClick={onDelete} onClick={onDelete}

View File

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

View File

@@ -1,7 +1,10 @@
import type { SessionSummary } from '../types/stats'; import type { SessionSummary } from '../types/stats';
import type { TabId } from '../components/layout/TabBar'; 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 { export interface MediaDetailState {
videoId: number; 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 { export function closeMediaDetail(state: StatsViewState): StatsViewState {
if (!state.mediaDetail) { if (!state.mediaDetail) {
return state; 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 { return {
activeTab: 'anime', activeTab: 'anime',
selectedAnimeId: state.mediaDetail.origin.animeId, selectedAnimeId: state.mediaDetail.origin.animeId,