mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
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
This commit is contained in:
@@ -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: '<span>連れる</span>' },
|
||||
Quote: { value: '<div>このまま<b>連れてって</b></div>' },
|
||||
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: '<span>連れる</span>' },
|
||||
Quote: { value: '<div>このまま<b>連れてって</b></div>' },
|
||||
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');
|
||||
|
||||
@@ -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<string> | null {
|
||||
if (!cachePath || !existsSync(cachePath)) return null;
|
||||
@@ -60,6 +75,7 @@ export interface StatsServerConfig {
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
}
|
||||
|
||||
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||
@@ -81,6 +97,17 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||
};
|
||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||
|
||||
function buildAnkiNotePreview(
|
||||
fields: Record<string, { value: string }>,
|
||||
ankiConfig?: Pick<AnkiConnectConfig, 'fields'>,
|
||||
): { 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<number | null>;
|
||||
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<string, { value: string }> }>;
|
||||
};
|
||||
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({
|
||||
|
||||
@@ -51,7 +51,7 @@ export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
|
||||
</span>
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-green">
|
||||
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-ctp-overlay2">
|
||||
|
||||
@@ -163,10 +163,7 @@ export function AnimeDetailView({
|
||||
anilistEntries={anilistEntries ?? []}
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
/>
|
||||
<AnimeOverviewStats
|
||||
detail={detail}
|
||||
knownWordsSummary={knownWordsSummary}
|
||||
/>
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||
|
||||
@@ -37,12 +37,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
if (cancelled) return;
|
||||
const map = new Map<number, NoteInfo>();
|
||||
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);
|
||||
|
||||
@@ -22,7 +22,13 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} onNavigateToSession={onNavigateToSession} />;
|
||||
return (
|
||||
<MediaDetailView
|
||||
videoId={selectedVideoId}
|
||||
onBack={() => setSelectedVideoId(null)}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
<StatCard
|
||||
label="Cards Mined Today"
|
||||
value={formatNumber(summary.todayCards)}
|
||||
color="text-ctp-green"
|
||||
color="text-ctp-cards-mined"
|
||||
/>
|
||||
<StatCard
|
||||
label="Sessions Today"
|
||||
|
||||
@@ -38,7 +38,7 @@ export function QuickStats({ rollups }: QuickStatsProps) {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Cards this week</span>
|
||||
<span className="text-ctp-green font-medium">{weekCards}</span>
|
||||
<span className="text-ctp-cards-mined font-medium">{weekCards}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
|
||||
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
|
||||
const requestedNoteIdsRef = useRef<Set<number>>(new Set());
|
||||
const pendingNoteIdsRef = useRef<Set<number>>(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);
|
||||
|
||||
@@ -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(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading
|
||||
pinned
|
||||
onTogglePinned={() => {}}
|
||||
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(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading={true}
|
||||
pinned={true}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Loading Anki note info/);
|
||||
assert.doesNotMatch(markup, /Preview unavailable/);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={noteId}
|
||||
@@ -114,7 +115,7 @@ export function SessionEventPopover({
|
||||
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
|
||||
Note {noteId}
|
||||
</div>
|
||||
{!hasPreview ? (
|
||||
{showUnavailableFallback ? (
|
||||
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -127,7 +128,7 @@ export function SessionEventPopover({
|
||||
{info?.meaning ? (
|
||||
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
||||
) : null}
|
||||
{!hasPreview ? (
|
||||
{showUnavailableFallback ? (
|
||||
<div className="mb-2 text-xs text-ctp-overlay1">
|
||||
Preview unavailable from AnkiConnect.
|
||||
</div>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
|
||||
style={{ backgroundColor: colors[i % colors.length] }}
|
||||
/>
|
||||
<span className="truncate">{key}</span>
|
||||
</span>
|
||||
|
||||
@@ -362,7 +362,7 @@ export function WordDetailPanel({
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<span>
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
|
||||
· session {occ.sessionId}
|
||||
@@ -400,7 +400,9 @@ export function WordDetailPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={unavailableReason ?? 'Mine this sentence from video clip'}
|
||||
title={
|
||||
unavailableReason ?? 'Mine this sentence from video clip'
|
||||
}
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'sentence')}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import { SESSION_CHART_EVENT_TYPES } from '../lib/session-events';
|
||||
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
|
||||
|
||||
export function useSessions(limit = 50) {
|
||||
@@ -65,7 +66,7 @@ export function useSessionDetail(sessionId: number | null) {
|
||||
const client = getStatsClient();
|
||||
Promise.all([
|
||||
client.getSessionTimeline(sessionId),
|
||||
client.getSessionEvents(sessionId),
|
||||
client.getSessionEvents(sessionId, 500, [...SESSION_CHART_EVENT_TYPES]),
|
||||
client.getSessionKnownWordsTimeline(sessionId),
|
||||
])
|
||||
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
|
||||
|
||||
@@ -109,11 +109,49 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
||||
|
||||
try {
|
||||
await apiClient.getTrendsDashboard('90d', 'month');
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents can request only specific event types', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.getSessionEvents(42, 120, [4, 5, 6, 7, 8, 9]);
|
||||
assert.equal(
|
||||
seenUrl,
|
||||
`${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`,
|
||||
`${BASE_URL}/api/stats/sessions/42/events?limit=120&types=4%2C5%2C6%2C7%2C8%2C9`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionTimeline requests full session history when limit is omitted', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.getSessionTimeline(42);
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42/timeline`);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
StatsAnkiNoteInfo,
|
||||
} from '../types/stats';
|
||||
|
||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||
@@ -76,8 +77,13 @@ export const apiClient = {
|
||||
? `/api/stats/sessions/${id}/timeline`
|
||||
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
|
||||
),
|
||||
getSessionEvents: (id: number, limit = 500) =>
|
||||
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
|
||||
getSessionEvents: (id: number, limit = 500, eventTypes?: number[]) => {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (eventTypes && eventTypes.length > 0) {
|
||||
params.set('types', eventTypes.join(','));
|
||||
}
|
||||
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
|
||||
},
|
||||
getSessionKnownWordsTimeline: (id: number) =>
|
||||
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
|
||||
`/api/stats/sessions/${id}/known-words-timeline`,
|
||||
@@ -142,7 +148,9 @@ export const apiClient = {
|
||||
},
|
||||
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
||||
getKnownWordsSummary: () =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>('/api/stats/known-words-summary'),
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
'/api/stats/known-words-summary',
|
||||
),
|
||||
getAnimeKnownWordsSummary: (animeId: number) =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
`/api/stats/anime/${animeId}/known-words-summary`,
|
||||
@@ -200,9 +208,7 @@ export const apiClient = {
|
||||
ankiBrowse: async (noteId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
||||
},
|
||||
ankiNotesInfo: async (
|
||||
noteIds: number[],
|
||||
): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
|
||||
ankiNotesInfo: async (noteIds: number[]): Promise<StatsAnkiNoteInfo[]> => {
|
||||
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
|
||||
import {
|
||||
confirmDayGroupDelete,
|
||||
confirmEpisodeDelete,
|
||||
confirmSessionDelete,
|
||||
} from './delete-confirm';
|
||||
|
||||
test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
StatsAnkiNoteInfo,
|
||||
} from '../types/stats';
|
||||
|
||||
interface StatsElectronAPI {
|
||||
@@ -59,9 +60,7 @@ interface StatsElectronAPI {
|
||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (
|
||||
noteIds: number[],
|
||||
) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||
hideOverlay: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
||||
import { EventType } from '../types/stats';
|
||||
import {
|
||||
buildSessionChartEvents,
|
||||
collectPendingSessionEventNoteIds,
|
||||
extractSessionEventNoteInfo,
|
||||
getSessionEventCardRequest,
|
||||
mergeSessionEventNoteInfos,
|
||||
projectSessionMarkerLeftPx,
|
||||
resolveActiveSessionMarkerKey,
|
||||
togglePinnedSessionMarkerKey,
|
||||
@@ -108,6 +111,28 @@ test('extractSessionEventNoteInfo prefers expression-like fields and strips html
|
||||
});
|
||||
});
|
||||
|
||||
test('extractSessionEventNoteInfo prefers explicit preview payload over field-name guessing', () => {
|
||||
const info = extractSessionEventNoteInfo({
|
||||
noteId: 92,
|
||||
preview: {
|
||||
word: '連れる',
|
||||
sentence: 'このまま 連れてって',
|
||||
translation: 'to take along',
|
||||
},
|
||||
fields: {
|
||||
UnexpectedWordField: { value: 'should not win' },
|
||||
UnexpectedSentenceField: { value: 'should not win either' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(info, {
|
||||
noteId: 92,
|
||||
expression: '連れる',
|
||||
context: 'このまま 連れてって',
|
||||
meaning: 'to take along',
|
||||
});
|
||||
});
|
||||
|
||||
test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => {
|
||||
assert.equal(
|
||||
extractSessionEventNoteInfo({
|
||||
@@ -120,6 +145,75 @@ test('extractSessionEventNoteInfo ignores malformed notes without a numeric note
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => {
|
||||
const noteInfos = mergeSessionEventNoteInfos([111], [
|
||||
{
|
||||
noteId: 222,
|
||||
fields: {
|
||||
Expression: { value: '呪い' },
|
||||
Sentence: { value: 'この剣は呪いだ' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(noteInfos.get(111), {
|
||||
noteId: 222,
|
||||
expression: '呪い',
|
||||
context: 'この剣は呪いだ',
|
||||
meaning: null,
|
||||
});
|
||||
assert.deepEqual(noteInfos.get(222), {
|
||||
noteId: 222,
|
||||
expression: '呪い',
|
||||
context: 'この剣は呪いだ',
|
||||
meaning: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('collectPendingSessionEventNoteIds supports strict-mode cleanup and refetch', () => {
|
||||
const noteInfos = new Map();
|
||||
const pendingNoteIds = new Set<number>();
|
||||
|
||||
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]);
|
||||
|
||||
pendingNoteIds.add(177);
|
||||
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []);
|
||||
|
||||
pendingNoteIds.delete(177);
|
||||
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]);
|
||||
|
||||
noteInfos.set(177, {
|
||||
noteId: 177,
|
||||
expression: '対抗',
|
||||
context: 'ダクネス 無理して 対抗 するな',
|
||||
meaning: null,
|
||||
});
|
||||
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []);
|
||||
});
|
||||
|
||||
test('getSessionEventCardRequest stays stable across rebuilt marker objects', () => {
|
||||
const events = [
|
||||
{
|
||||
eventType: EventType.CARD_MINED,
|
||||
tsMs: 6_000,
|
||||
payload: '{"cardsMined":1,"noteIds":[1773808840964]}',
|
||||
},
|
||||
];
|
||||
|
||||
const firstMarker = buildSessionChartEvents(events).markers[0]!;
|
||||
const secondMarker = buildSessionChartEvents(events).markers[0]!;
|
||||
|
||||
assert.notEqual(firstMarker, secondMarker);
|
||||
assert.deepEqual(getSessionEventCardRequest(firstMarker), {
|
||||
noteIds: [1773808840964],
|
||||
requestKey: 'card-6000:1773808840964',
|
||||
});
|
||||
assert.deepEqual(getSessionEventCardRequest(secondMarker), {
|
||||
noteIds: [1773808840964],
|
||||
requestKey: 'card-6000:1773808840964',
|
||||
});
|
||||
});
|
||||
|
||||
test('session marker pin helpers prefer pinned markers and toggle on repeat clicks', () => {
|
||||
assert.equal(resolveActiveSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
|
||||
assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1');
|
||||
|
||||
@@ -40,6 +40,11 @@ interface SessionEventNoteField {
|
||||
|
||||
interface SessionEventNoteRecord {
|
||||
noteId: unknown;
|
||||
preview?: {
|
||||
word?: unknown;
|
||||
sentence?: unknown;
|
||||
translation?: unknown;
|
||||
} | null;
|
||||
fields?: Record<string, SessionEventNoteField> | null;
|
||||
}
|
||||
|
||||
@@ -145,6 +150,21 @@ export function extractSessionEventNoteInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewExpression =
|
||||
typeof note.preview?.word === 'string' ? stripHtml(note.preview.word) : '';
|
||||
const previewContext =
|
||||
typeof note.preview?.sentence === 'string' ? stripHtml(note.preview.sentence) : '';
|
||||
const previewMeaning =
|
||||
typeof note.preview?.translation === 'string' ? stripHtml(note.preview.translation) : '';
|
||||
if (previewExpression || previewContext || previewMeaning) {
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
expression: previewExpression,
|
||||
context: previewContext || null,
|
||||
meaning: previewMeaning || null,
|
||||
};
|
||||
}
|
||||
|
||||
const fields = note.fields ?? {};
|
||||
const expression = pickExpressionField(fields);
|
||||
const usedValues = new Set<string>(expression ? [expression] : []);
|
||||
@@ -175,6 +195,67 @@ export function extractSessionEventNoteInfo(
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeSessionEventNoteInfos(
|
||||
requestedNoteIds: number[],
|
||||
notes: SessionEventNoteRecord[],
|
||||
): Map<number, SessionEventNoteInfo> {
|
||||
const next = new Map<number, SessionEventNoteInfo>();
|
||||
|
||||
notes.forEach((note, index) => {
|
||||
const info = extractSessionEventNoteInfo(note);
|
||||
if (!info) return;
|
||||
next.set(info.noteId, info);
|
||||
|
||||
const requestedNoteId = requestedNoteIds[index];
|
||||
if (requestedNoteId && requestedNoteId > 0) {
|
||||
next.set(requestedNoteId, info);
|
||||
}
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function collectPendingSessionEventNoteIds(
|
||||
noteIds: number[],
|
||||
noteInfos: ReadonlyMap<number, SessionEventNoteInfo>,
|
||||
pendingNoteIds: ReadonlySet<number>,
|
||||
): number[] {
|
||||
const next: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
if (!Number.isInteger(noteId) || noteId <= 0 || seen.has(noteId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(noteId);
|
||||
if (noteInfos.has(noteId) || pendingNoteIds.has(noteId)) {
|
||||
continue;
|
||||
}
|
||||
next.push(noteId);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getSessionEventCardRequest(
|
||||
marker: SessionChartMarker | null,
|
||||
): { noteIds: number[]; requestKey: string | null } {
|
||||
if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) {
|
||||
return { noteIds: [], requestKey: null };
|
||||
}
|
||||
|
||||
const noteIds = Array.from(
|
||||
new Set(
|
||||
marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
noteIds,
|
||||
requestKey: noteIds.length > 0 ? `${marker.key}:${noteIds.join(',')}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveActiveSessionMarkerKey(
|
||||
hoveredMarkerKey: string | null,
|
||||
pinnedMarkerKey: string | null,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
--color-ctp-overlay0: #6e738d;
|
||||
--color-ctp-blue: #8aadf4;
|
||||
--color-ctp-green: #a6da95;
|
||||
--color-ctp-cards-mined: #f5bde6;
|
||||
--color-ctp-mauve: #c6a0f6;
|
||||
--color-ctp-peach: #f5a97f;
|
||||
--color-ctp-red: #ed8796;
|
||||
|
||||
@@ -46,6 +46,18 @@ export interface SessionEvent {
|
||||
payload: string | null;
|
||||
}
|
||||
|
||||
export interface AnkiNotePreview {
|
||||
word: string;
|
||||
sentence: string;
|
||||
translation: string;
|
||||
}
|
||||
|
||||
export interface StatsAnkiNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
preview?: AnkiNotePreview;
|
||||
}
|
||||
|
||||
export interface VocabularyEntry {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
|
||||
Reference in New Issue
Block a user