From 97126caf4e2a7f123bb19d1681a7e320aa5d40d5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 18 Mar 2026 02:24:38 -0700 Subject: [PATCH] feat(stats): add note ID resolution and session event handling improvements - Add note ID resolution through merge redirects in stats API - Build Anki note previews using configured field names - Add session event helpers for merged note dedup and stable request keys - Refactor SessionDetail to prevent redundant note info requests - Add session event popover and API client tests --- .../services/__tests__/stats-server.test.ts | 118 ++++++++++++++++++ src/core/services/stats-server.ts | 56 ++++++++- stats/src/components/anime/AnimeCardsList.tsx | 2 +- .../src/components/anime/AnimeDetailView.tsx | 5 +- stats/src/components/anime/EpisodeDetail.tsx | 7 +- stats/src/components/library/LibraryTab.tsx | 8 +- stats/src/components/overview/HeroStats.tsx | 2 +- stats/src/components/overview/QuickStats.tsx | 2 +- .../src/components/sessions/SessionDetail.tsx | 41 ++++-- .../sessions/SessionEventPopover.test.tsx | 52 ++++++++ .../sessions/SessionEventPopover.tsx | 5 +- stats/src/components/sessions/SessionsTab.tsx | 4 +- .../components/trends/StackedTrendChart.tsx | 12 +- .../components/vocabulary/WordDetailPanel.tsx | 6 +- stats/src/hooks/useSessions.ts | 3 +- stats/src/lib/api-client.test.ts | 40 +++++- stats/src/lib/api-client.ts | 18 ++- stats/src/lib/delete-confirm.test.ts | 6 +- stats/src/lib/ipc-client.ts | 5 +- stats/src/lib/session-events.test.ts | 94 ++++++++++++++ stats/src/lib/session-events.ts | 81 ++++++++++++ stats/src/styles/globals.css | 1 + stats/src/types/stats.ts | 12 ++ 23 files changed, 528 insertions(+), 52 deletions(-) diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 807ce9b..da9d9ac 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -898,6 +898,124 @@ describe('stats server API routes', () => { assert.equal(res.status, 400); }); + it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => { + const originalFetch = globalThis.fetch; + const requests: unknown[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(init?.body ? JSON.parse(String(init.body)) : null); + return new Response( + JSON.stringify({ + result: [ + { + noteId: 222, + fields: { + Expression: { value: '呪い' }, + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }) as typeof fetch; + + try { + const app = createStatsApp(createMockTracker(), { + resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId), + }); + const res = await app.request('/api/stats/anki/notesInfo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteIds: [111] }), + }); + + assert.equal(res.status, 200); + assert.deepEqual(requests, [ + { + action: 'notesInfo', + version: 6, + params: { notes: [222] }, + }, + ]); + assert.deepEqual(await res.json(), [ + { + noteId: 222, + fields: { + Expression: { value: '呪い' }, + }, + preview: { + word: '呪い', + sentence: '', + translation: '', + }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('POST /api/stats/anki/notesInfo returns preview fields using configured word and sentence field names', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + result: [ + { + noteId: 333, + fields: { + TargetWord: { value: '連れる' }, + Quote: { value: '
このまま連れてって
' }, + SelectionText: { value: 'to take along' }, + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + )) as typeof fetch; + + try { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { + fields: { + word: 'TargetWord', + sentence: 'Quote', + translation: 'SelectionText', + }, + }, + }); + const res = await app.request('/api/stats/anki/notesInfo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteIds: [333] }), + }); + + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), [ + { + noteId: 333, + fields: { + TargetWord: { value: '連れる' }, + Quote: { value: '
このまま連れてって
' }, + SelectionText: { value: 'to take along' }, + }, + preview: { + word: '連れる', + sentence: 'このまま 連れてって', + translation: 'to take along', + }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('serves stats index and asset files from absolute static dir paths', async () => { await withTempDir(async (dir) => { const assetDir = path.join(dir, 'assets'); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 3d88b01..c029907 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -6,6 +6,12 @@ import { readFileSync, existsSync, statSync } from 'node:fs'; import { MediaGenerator } from '../../media-generator.js'; import { AnkiConnectClient } from '../../anki-connect.js'; import type { AnkiConnectConfig } from '../../types.js'; +import { + getConfiguredSentenceFieldName, + getConfiguredTranslationFieldName, + getConfiguredWordFieldName, + getPreferredNoteFieldValue, +} from '../../anki-field-config.js'; function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { if (raw === undefined) return fallback; @@ -25,6 +31,15 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { return raw === 'month' ? 'month' : 'day'; } +function parseEventTypesQuery(raw: string | undefined): number[] | undefined { + if (!raw) return undefined; + const parsed = raw + .split(',') + .map((entry) => Number.parseInt(entry.trim(), 10)) + .filter((entry) => Number.isInteger(entry) && entry > 0); + return parsed.length > 0 ? parsed : undefined; +} + /** Load known words cache from disk into a Set. Returns null if unavailable. */ function loadKnownWordsSet(cachePath: string | undefined): Set | null { if (!cachePath || !existsSync(cachePath)) return null; @@ -60,6 +75,7 @@ export interface StatsServerConfig { mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; addYomitanNote?: (word: string) => Promise; + resolveAnkiNoteId?: (noteId: number) => number; } const STATS_STATIC_CONTENT_TYPES: Record = { @@ -81,6 +97,17 @@ const STATS_STATIC_CONTENT_TYPES: Record = { }; const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; +function buildAnkiNotePreview( + fields: Record, + ankiConfig?: Pick, +): { word: string; sentence: string; translation: string } { + return { + word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]), + sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]), + translation: getPreferredNoteFieldValue(fields, [getConfiguredTranslationFieldName(ankiConfig)]), + }; +} + function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null { const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html'; const decodedPath = decodeURIComponent(normalizedPath); @@ -129,6 +156,7 @@ export function createStatsApp( mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; addYomitanNote?: (word: string) => Promise; + resolveAnkiNoteId?: (noteId: number) => number; }, ) { const app = new Hono(); @@ -199,7 +227,8 @@ export function createStatsApp( const id = parseIntQuery(c.req.param('id'), 0); if (id <= 0) return c.json([], 400); const limit = parseIntQuery(c.req.query('limit'), 500, 1000); - const events = await tracker.getSessionEvents(id, limit); + const eventTypes = parseEventTypesQuery(c.req.query('types')); + const events = await tracker.getSessionEvents(id, limit, eventTypes); return c.json(events); }); @@ -509,23 +538,38 @@ export function createStatsApp( app.post('/api/stats/anki/notesInfo', async (c) => { const body = await c.req.json().catch(() => null); - const noteIds = Array.isArray(body?.noteIds) + const noteIds: number[] = Array.isArray(body?.noteIds) ? body.noteIds.filter( (id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0, ) : []; if (noteIds.length === 0) return c.json([]); + const resolvedNoteIds = Array.from( + new Set( + noteIds.map((noteId) => { + const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId); + return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0 + ? (resolvedNoteId as number) + : noteId; + }), + ), + ); try { const response = await fetch('http://127.0.0.1:8765', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), - body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }), + body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: resolvedNoteIds } }), }); const result = (await response.json()) as { result?: Array<{ noteId: number; fields: Record }>; }; - return c.json(result.result ?? []); + return c.json( + (result.result ?? []).map((note) => ({ + ...note, + preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig), + })), + ); } catch { return c.json([], 502); } @@ -710,6 +754,7 @@ export function createStatsApp( if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`); + const wordFieldName = getConfiguredWordFieldName(ankiConfig); const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio'; @@ -726,7 +771,7 @@ export function createStatsApp( if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) { if (word) { - fields['Expression'] = word; + fields[wordFieldName] = word; } if (mode === 'sentence') { fields['IsSentenceCard'] = 'x'; @@ -831,6 +876,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void mpvSocketPath: config.mpvSocketPath, ankiConnectConfig: config.ankiConnectConfig, addYomitanNote: config.addYomitanNote, + resolveAnkiNoteId: config.resolveAnkiNoteId, }); const server = serve({ diff --git a/stats/src/components/anime/AnimeCardsList.tsx b/stats/src/components/anime/AnimeCardsList.tsx index 739a0d7..4a157dc 100644 --- a/stats/src/components/anime/AnimeCardsList.tsx +++ b/stats/src/components/anime/AnimeCardsList.tsx @@ -51,7 +51,7 @@ export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) { {ep.canonicalTitle} - + {formatNumber(ep.totalCards)} diff --git a/stats/src/components/anime/AnimeDetailView.tsx b/stats/src/components/anime/AnimeDetailView.tsx index bbb299c..d321b4f 100644 --- a/stats/src/components/anime/AnimeDetailView.tsx +++ b/stats/src/components/anime/AnimeDetailView.tsx @@ -163,10 +163,7 @@ export function AnimeDetailView({ anilistEntries={anilistEntries ?? []} onChangeAnilist={() => setShowAnilistSelector(true)} /> - + onOpenEpisodeDetail(videoId) : undefined} diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index 9502db9..da3daf7 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -37,12 +37,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) if (cancelled) return; const map = new Map(); for (const note of notes) { - const expr = - note.fields?.Expression?.value ?? - note.fields?.expression?.value ?? - note.fields?.Word?.value ?? - note.fields?.word?.value ?? - ''; + const expr = note.preview?.word ?? ''; map.set(note.noteId, { noteId: note.noteId, expression: expr }); } setNoteInfos(map); diff --git a/stats/src/components/library/LibraryTab.tsx b/stats/src/components/library/LibraryTab.tsx index 55b9595..217fbec 100644 --- a/stats/src/components/library/LibraryTab.tsx +++ b/stats/src/components/library/LibraryTab.tsx @@ -22,7 +22,13 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0); if (selectedVideoId !== null) { - return setSelectedVideoId(null)} onNavigateToSession={onNavigateToSession} />; + return ( + setSelectedVideoId(null)} + onNavigateToSession={onNavigateToSession} + /> + ); } if (loading) return
Loading...
; diff --git a/stats/src/components/overview/HeroStats.tsx b/stats/src/components/overview/HeroStats.tsx index 68266e5..9c11f18 100644 --- a/stats/src/components/overview/HeroStats.tsx +++ b/stats/src/components/overview/HeroStats.tsx @@ -22,7 +22,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
Cards this week - {weekCards} + {weekCards}
diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index ddf8f45..6128d32 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -19,7 +19,9 @@ import type { KnownWordsTimelinePoint } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; import { buildSessionChartEvents, - extractSessionEventNoteInfo, + collectPendingSessionEventNoteIds, + getSessionEventCardRequest, + mergeSessionEventNoteInfos, resolveActiveSessionMarkerKey, type SessionChartMarker, type SessionEventNoteInfo, @@ -119,7 +121,7 @@ export function SessionDetail({ session }: SessionDetailProps) { const [pinnedMarkerKey, setPinnedMarkerKey] = useState(null); const [noteInfos, setNoteInfos] = useState>(new Map()); const [loadingNoteIds, setLoadingNoteIds] = useState>(new Set()); - const requestedNoteIdsRef = useRef>(new Set()); + const pendingNoteIdsRef = useRef>(new Set()); const sorted = [...timeline].reverse(); const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); @@ -139,21 +141,27 @@ export function SessionDetail({ session }: SessionDetailProps) { () => markers.find((marker) => marker.key === activeMarkerKey) ?? null, [markers, activeMarkerKey], ); + const activeCardRequest = useMemo( + () => getSessionEventCardRequest(activeMarker), + [activeMarkerKey, markers], + ); useEffect(() => { - if (!activeMarker || activeMarker.kind !== 'card' || activeMarker.noteIds.length === 0) { + if (!activeCardRequest.requestKey || activeCardRequest.noteIds.length === 0) { return; } - const missingNoteIds = activeMarker.noteIds.filter( - (noteId) => !requestedNoteIdsRef.current.has(noteId) && !noteInfos.has(noteId), + const missingNoteIds = collectPendingSessionEventNoteIds( + activeCardRequest.noteIds, + noteInfos, + pendingNoteIdsRef.current, ); if (missingNoteIds.length === 0) { return; } for (const noteId of missingNoteIds) { - requestedNoteIdsRef.current.add(noteId); + pendingNoteIdsRef.current.add(noteId); } let cancelled = false; @@ -171,10 +179,8 @@ export function SessionDetail({ session }: SessionDetailProps) { if (cancelled) return; setNoteInfos((prev) => { const next = new Map(prev); - for (const note of notes) { - const info = extractSessionEventNoteInfo(note); - if (!info) continue; - next.set(info.noteId, info); + for (const [noteId, info] of mergeSessionEventNoteInfos(missingNoteIds, notes)) { + next.set(noteId, info); } return next; }); @@ -186,6 +192,9 @@ export function SessionDetail({ session }: SessionDetailProps) { }) .finally(() => { if (cancelled) return; + for (const noteId of missingNoteIds) { + pendingNoteIdsRef.current.delete(noteId); + } setLoadingNoteIds((prev) => { const next = new Set(prev); for (const noteId of missingNoteIds) { @@ -197,8 +206,18 @@ export function SessionDetail({ session }: SessionDetailProps) { return () => { cancelled = true; + for (const noteId of missingNoteIds) { + pendingNoteIdsRef.current.delete(noteId); + } + setLoadingNoteIds((prev) => { + const next = new Set(prev); + for (const noteId of missingNoteIds) { + next.delete(noteId); + } + return next; + }); }; - }, [activeMarker, noteInfos]); + }, [activeCardRequest.requestKey, noteInfos]); const handleOpenNote = (noteId: number) => { void getStatsClient().ankiBrowse(noteId); diff --git a/stats/src/components/sessions/SessionEventPopover.test.tsx b/stats/src/components/sessions/SessionEventPopover.test.tsx index 5b0a136..801d5dd 100644 --- a/stats/src/components/sessions/SessionEventPopover.test.tsx +++ b/stats/src/components/sessions/SessionEventPopover.test.tsx @@ -96,3 +96,55 @@ test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides n assert.match(markup, /Preview unavailable from AnkiConnect/); assert.doesNotMatch(markup, /No readable note fields returned/); }); + +test('SessionEventPopover hides preview-unavailable fallback while note info is still loading', () => { + const marker: SessionChartMarker = { + key: 'card-177', + kind: 'card', + anchorTsMs: 9_000, + eventTsMs: 9_000, + noteIds: [177], + cardsDelta: 1, + }; + + const markup = renderToStaticMarkup( + {}} + onClose={() => {}} + onOpenNote={() => {}} + />, + ); + + assert.match(markup, /Loading Anki note info/); + assert.doesNotMatch(markup, /Preview unavailable/); +}); + +test('SessionEventPopover keeps the loading state clean until note preview data arrives', () => { + const marker: SessionChartMarker = { + key: 'card-9001', + kind: 'card', + anchorTsMs: 9_001, + eventTsMs: 9_001, + noteIds: [1773808840964], + cardsDelta: 1, + }; + + const markup = renderToStaticMarkup( + {}} + onClose={() => {}} + onOpenNote={() => {}} + />, + ); + + assert.match(markup, /Loading Anki note info/); + assert.doesNotMatch(markup, /Preview unavailable/); +}); diff --git a/stats/src/components/sessions/SessionEventPopover.tsx b/stats/src/components/sessions/SessionEventPopover.tsx index fca6a0e..7d013ee 100644 --- a/stats/src/components/sessions/SessionEventPopover.tsx +++ b/stats/src/components/sessions/SessionEventPopover.tsx @@ -105,6 +105,7 @@ export function SessionEventPopover({ marker.noteIds.map((noteId) => { const info = noteInfos.get(noteId); const hasPreview = Boolean(info?.expression || info?.context || info?.meaning); + const showUnavailableFallback = !loading && !hasPreview; return (
Note {noteId}
- {!hasPreview ? ( + {showUnavailableFallback ? (
Preview unavailable
) : null} @@ -127,7 +128,7 @@ export function SessionEventPopover({ {info?.meaning ? (
{info.meaning}
) : null} - {!hasPreview ? ( + {showUnavailableFallback ? (
Preview unavailable from AnkiConnect.
diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index 995d999..77388d3 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -52,7 +52,9 @@ export function SessionsTab({ initialSessionId, onClearInitialSession }: Session el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else { // Session row itself if detail hasn't rendered yet - const row = document.querySelector(`[aria-controls="session-details-${initialSessionId}"]`); + const row = document.querySelector( + `[aria-controls="session-details-${initialSessionId}"]`, + ); row?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); diff --git a/stats/src/components/trends/StackedTrendChart.tsx b/stats/src/components/trends/StackedTrendChart.tsx index 3a2f8de..c56a8bc 100644 --- a/stats/src/components/trends/StackedTrendChart.tsx +++ b/stats/src/components/trends/StackedTrendChart.tsx @@ -10,9 +10,10 @@ export interface PerAnimeDataPoint { interface StackedTrendChartProps { title: string; data: PerAnimeDataPoint[]; + colorPalette?: string[]; } -const LINE_COLORS = [ +const DEFAULT_LINE_COLORS = [ '#8aadf4', '#c6a0f6', '#a6da95', @@ -59,8 +60,9 @@ function buildLineData(raw: PerAnimeDataPoint[]) { return { points, seriesKeys: topTitles }; } -export function StackedTrendChart({ title, data }: StackedTrendChartProps) { +export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) { const { points, seriesKeys } = buildLineData(data); + const colors = colorPalette ?? DEFAULT_LINE_COLORS; const tooltipStyle = { background: '#363a4f', @@ -102,8 +104,8 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) { key={key} type="monotone" dataKey={key} - stroke={LINE_COLORS[i % LINE_COLORS.length]} - fill={LINE_COLORS[i % LINE_COLORS.length]} + stroke={colors[i % colors.length]} + fill={colors[i % colors.length]} fillOpacity={0.15} strokeWidth={1.5} connectNulls @@ -120,7 +122,7 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) { > {key} diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx index 567f94a..6aa8f25 100644 --- a/stats/src/components/vocabulary/WordDetailPanel.tsx +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -362,7 +362,7 @@ export function WordDetailPanel({ {formatNumber(occ.occurrenceCount)} in line -
+
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '} · session {occ.sessionId} @@ -400,7 +400,9 @@ export function WordDetailPanel({