mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -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);
|
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 () => {
|
it('serves stats index and asset files from absolute static dir paths', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const assetDir = path.join(dir, 'assets');
|
const assetDir = path.join(dir, 'assets');
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|||||||
import { MediaGenerator } from '../../media-generator.js';
|
import { MediaGenerator } from '../../media-generator.js';
|
||||||
import { AnkiConnectClient } from '../../anki-connect.js';
|
import { AnkiConnectClient } from '../../anki-connect.js';
|
||||||
import type { AnkiConnectConfig } from '../../types.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 {
|
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||||
if (raw === undefined) return fallback;
|
if (raw === undefined) return fallback;
|
||||||
@@ -25,6 +31,15 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
|||||||
return raw === 'month' ? 'month' : 'day';
|
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. */
|
/** Load known words cache from disk into a Set. Returns null if unavailable. */
|
||||||
function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||||
if (!cachePath || !existsSync(cachePath)) return null;
|
if (!cachePath || !existsSync(cachePath)) return null;
|
||||||
@@ -60,6 +75,7 @@ export interface StatsServerConfig {
|
|||||||
mpvSocketPath?: string;
|
mpvSocketPath?: string;
|
||||||
ankiConnectConfig?: AnkiConnectConfig;
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
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;
|
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 {
|
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
|
||||||
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
|
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
|
||||||
const decodedPath = decodeURIComponent(normalizedPath);
|
const decodedPath = decodeURIComponent(normalizedPath);
|
||||||
@@ -129,6 +156,7 @@ export function createStatsApp(
|
|||||||
mpvSocketPath?: string;
|
mpvSocketPath?: string;
|
||||||
ankiConnectConfig?: AnkiConnectConfig;
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -199,7 +227,8 @@ export function createStatsApp(
|
|||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
if (id <= 0) return c.json([], 400);
|
if (id <= 0) return c.json([], 400);
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
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);
|
return c.json(events);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,23 +538,38 @@ export function createStatsApp(
|
|||||||
|
|
||||||
app.post('/api/stats/anki/notesInfo', async (c) => {
|
app.post('/api/stats/anki/notesInfo', async (c) => {
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const noteIds = Array.isArray(body?.noteIds)
|
const noteIds: number[] = Array.isArray(body?.noteIds)
|
||||||
? body.noteIds.filter(
|
? body.noteIds.filter(
|
||||||
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
if (noteIds.length === 0) return c.json([]);
|
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 {
|
try {
|
||||||
const response = await fetch('http://127.0.0.1:8765', {
|
const response = await fetch('http://127.0.0.1:8765', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
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 {
|
const result = (await response.json()) as {
|
||||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
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 {
|
} catch {
|
||||||
return c.json([], 502);
|
return c.json([], 502);
|
||||||
}
|
}
|
||||||
@@ -710,6 +754,7 @@ export function createStatsApp(
|
|||||||
if (imageResult.status === 'rejected')
|
if (imageResult.status === 'rejected')
|
||||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||||
|
|
||||||
|
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||||
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||||
@@ -726,7 +771,7 @@ export function createStatsApp(
|
|||||||
|
|
||||||
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
||||||
if (word) {
|
if (word) {
|
||||||
fields['Expression'] = word;
|
fields[wordFieldName] = word;
|
||||||
}
|
}
|
||||||
if (mode === 'sentence') {
|
if (mode === 'sentence') {
|
||||||
fields['IsSentenceCard'] = 'x';
|
fields['IsSentenceCard'] = 'x';
|
||||||
@@ -831,6 +876,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
mpvSocketPath: config.mpvSocketPath,
|
mpvSocketPath: config.mpvSocketPath,
|
||||||
ankiConnectConfig: config.ankiConnectConfig,
|
ankiConnectConfig: config.ankiConnectConfig,
|
||||||
addYomitanNote: config.addYomitanNote,
|
addYomitanNote: config.addYomitanNote,
|
||||||
|
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
|
|||||||
</span>
|
</span>
|
||||||
{ep.canonicalTitle}
|
{ep.canonicalTitle}
|
||||||
</td>
|
</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)}
|
{formatNumber(ep.totalCards)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right text-ctp-overlay2">
|
<td className="py-2 text-right text-ctp-overlay2">
|
||||||
|
|||||||
@@ -163,10 +163,7 @@ export function AnimeDetailView({
|
|||||||
anilistEntries={anilistEntries ?? []}
|
anilistEntries={anilistEntries ?? []}
|
||||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||||
/>
|
/>
|
||||||
<AnimeOverviewStats
|
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||||
detail={detail}
|
|
||||||
knownWordsSummary={knownWordsSummary}
|
|
||||||
/>
|
|
||||||
<EpisodeList
|
<EpisodeList
|
||||||
episodes={episodes}
|
episodes={episodes}
|
||||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||||
|
|||||||
@@ -37,12 +37,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const map = new Map<number, NoteInfo>();
|
const map = new Map<number, NoteInfo>();
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const expr =
|
const expr = note.preview?.word ?? '';
|
||||||
note.fields?.Expression?.value ??
|
|
||||||
note.fields?.expression?.value ??
|
|
||||||
note.fields?.Word?.value ??
|
|
||||||
note.fields?.word?.value ??
|
|
||||||
'';
|
|
||||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||||
}
|
}
|
||||||
setNoteInfos(map);
|
setNoteInfos(map);
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||||
|
|
||||||
if (selectedVideoId !== null) {
|
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>;
|
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Cards Mined Today"
|
label="Cards Mined Today"
|
||||||
value={formatNumber(summary.todayCards)}
|
value={formatNumber(summary.todayCards)}
|
||||||
color="text-ctp-green"
|
color="text-ctp-cards-mined"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Sessions Today"
|
label="Sessions Today"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function QuickStats({ rollups }: QuickStatsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-ctp-subtext0">Cards this week</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
|
|||||||
import { CHART_THEME } from '../../lib/chart-theme';
|
import { CHART_THEME } from '../../lib/chart-theme';
|
||||||
import {
|
import {
|
||||||
buildSessionChartEvents,
|
buildSessionChartEvents,
|
||||||
extractSessionEventNoteInfo,
|
collectPendingSessionEventNoteIds,
|
||||||
|
getSessionEventCardRequest,
|
||||||
|
mergeSessionEventNoteInfos,
|
||||||
resolveActiveSessionMarkerKey,
|
resolveActiveSessionMarkerKey,
|
||||||
type SessionChartMarker,
|
type SessionChartMarker,
|
||||||
type SessionEventNoteInfo,
|
type SessionEventNoteInfo,
|
||||||
@@ -119,7 +121,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
const [pinnedMarkerKey, setPinnedMarkerKey] = useState<string | null>(null);
|
const [pinnedMarkerKey, setPinnedMarkerKey] = useState<string | null>(null);
|
||||||
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
|
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
|
||||||
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
|
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 sorted = [...timeline].reverse();
|
||||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||||
@@ -139,21 +141,27 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
|
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
|
||||||
[markers, activeMarkerKey],
|
[markers, activeMarkerKey],
|
||||||
);
|
);
|
||||||
|
const activeCardRequest = useMemo(
|
||||||
|
() => getSessionEventCardRequest(activeMarker),
|
||||||
|
[activeMarkerKey, markers],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeMarker || activeMarker.kind !== 'card' || activeMarker.noteIds.length === 0) {
|
if (!activeCardRequest.requestKey || activeCardRequest.noteIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingNoteIds = activeMarker.noteIds.filter(
|
const missingNoteIds = collectPendingSessionEventNoteIds(
|
||||||
(noteId) => !requestedNoteIdsRef.current.has(noteId) && !noteInfos.has(noteId),
|
activeCardRequest.noteIds,
|
||||||
|
noteInfos,
|
||||||
|
pendingNoteIdsRef.current,
|
||||||
);
|
);
|
||||||
if (missingNoteIds.length === 0) {
|
if (missingNoteIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const noteId of missingNoteIds) {
|
for (const noteId of missingNoteIds) {
|
||||||
requestedNoteIdsRef.current.add(noteId);
|
pendingNoteIdsRef.current.add(noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -171,10 +179,8 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setNoteInfos((prev) => {
|
setNoteInfos((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
for (const note of notes) {
|
for (const [noteId, info] of mergeSessionEventNoteInfos(missingNoteIds, notes)) {
|
||||||
const info = extractSessionEventNoteInfo(note);
|
next.set(noteId, info);
|
||||||
if (!info) continue;
|
|
||||||
next.set(info.noteId, info);
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -186,6 +192,9 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
for (const noteId of missingNoteIds) {
|
||||||
|
pendingNoteIdsRef.current.delete(noteId);
|
||||||
|
}
|
||||||
setLoadingNoteIds((prev) => {
|
setLoadingNoteIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
for (const noteId of missingNoteIds) {
|
for (const noteId of missingNoteIds) {
|
||||||
@@ -197,8 +206,18 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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) => {
|
const handleOpenNote = (noteId: number) => {
|
||||||
void getStatsClient().ankiBrowse(noteId);
|
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.match(markup, /Preview unavailable from AnkiConnect/);
|
||||||
assert.doesNotMatch(markup, /No readable note fields returned/);
|
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) => {
|
marker.noteIds.map((noteId) => {
|
||||||
const info = noteInfos.get(noteId);
|
const info = noteInfos.get(noteId);
|
||||||
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
|
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
|
||||||
|
const showUnavailableFallback = !loading && !hasPreview;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={noteId}
|
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">
|
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
|
||||||
Note {noteId}
|
Note {noteId}
|
||||||
</div>
|
</div>
|
||||||
{!hasPreview ? (
|
{showUnavailableFallback ? (
|
||||||
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +128,7 @@ export function SessionEventPopover({
|
|||||||
{info?.meaning ? (
|
{info?.meaning ? (
|
||||||
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!hasPreview ? (
|
{showUnavailableFallback ? (
|
||||||
<div className="mb-2 text-xs text-ctp-overlay1">
|
<div className="mb-2 text-xs text-ctp-overlay1">
|
||||||
Preview unavailable from AnkiConnect.
|
Preview unavailable from AnkiConnect.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function SessionsTab({ initialSessionId, onClearInitialSession }: Session
|
|||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
} else {
|
} else {
|
||||||
// Session row itself if detail hasn't rendered yet
|
// 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' });
|
row?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ export interface PerAnimeDataPoint {
|
|||||||
interface StackedTrendChartProps {
|
interface StackedTrendChartProps {
|
||||||
title: string;
|
title: string;
|
||||||
data: PerAnimeDataPoint[];
|
data: PerAnimeDataPoint[];
|
||||||
|
colorPalette?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINE_COLORS = [
|
const DEFAULT_LINE_COLORS = [
|
||||||
'#8aadf4',
|
'#8aadf4',
|
||||||
'#c6a0f6',
|
'#c6a0f6',
|
||||||
'#a6da95',
|
'#a6da95',
|
||||||
@@ -59,8 +60,9 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
|
|||||||
return { points, seriesKeys: topTitles };
|
return { points, seriesKeys: topTitles };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
|
||||||
const { points, seriesKeys } = buildLineData(data);
|
const { points, seriesKeys } = buildLineData(data);
|
||||||
|
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
background: '#363a4f',
|
background: '#363a4f',
|
||||||
@@ -102,8 +104,8 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
|||||||
key={key}
|
key={key}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={key}
|
dataKey={key}
|
||||||
stroke={LINE_COLORS[i % LINE_COLORS.length]}
|
stroke={colors[i % colors.length]}
|
||||||
fill={LINE_COLORS[i % LINE_COLORS.length]}
|
fill={colors[i % colors.length]}
|
||||||
fillOpacity={0.15}
|
fillOpacity={0.15}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
connectNulls
|
connectNulls
|
||||||
@@ -120,7 +122,7 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
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 className="truncate">{key}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export function WordDetailPanel({
|
|||||||
{formatNumber(occ.occurrenceCount)} in line
|
{formatNumber(occ.occurrenceCount)} in line
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span>
|
||||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
|
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
|
||||||
· session {occ.sessionId}
|
· session {occ.sessionId}
|
||||||
@@ -400,7 +400,9 @@ export function WordDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
disabled={sentenceStatus?.loading || !!unavailableReason}
|
||||||
onClick={() => void handleMine(occ, 'sentence')}
|
onClick={() => void handleMine(occ, 'sentence')}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
|
import { SESSION_CHART_EVENT_TYPES } from '../lib/session-events';
|
||||||
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
|
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
|
||||||
|
|
||||||
export function useSessions(limit = 50) {
|
export function useSessions(limit = 50) {
|
||||||
@@ -65,7 +66,7 @@ export function useSessionDetail(sessionId: number | null) {
|
|||||||
const client = getStatsClient();
|
const client = getStatsClient();
|
||||||
Promise.all([
|
Promise.all([
|
||||||
client.getSessionTimeline(sessionId),
|
client.getSessionTimeline(sessionId),
|
||||||
client.getSessionEvents(sessionId),
|
client.getSessionEvents(sessionId, 500, [...SESSION_CHART_EVENT_TYPES]),
|
||||||
client.getSessionKnownWordsTimeline(sessionId),
|
client.getSessionKnownWordsTimeline(sessionId),
|
||||||
])
|
])
|
||||||
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
|
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
|
||||||
|
|||||||
@@ -109,11 +109,49 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.getTrendsDashboard('90d', 'month');
|
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(
|
assert.equal(
|
||||||
seenUrl,
|
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 {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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,
|
WordDetailData,
|
||||||
KanjiDetailData,
|
KanjiDetailData,
|
||||||
EpisodeDetailData,
|
EpisodeDetailData,
|
||||||
|
StatsAnkiNoteInfo,
|
||||||
} from '../types/stats';
|
} from '../types/stats';
|
||||||
|
|
||||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||||
@@ -76,8 +77,13 @@ export const apiClient = {
|
|||||||
? `/api/stats/sessions/${id}/timeline`
|
? `/api/stats/sessions/${id}/timeline`
|
||||||
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
|
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
|
||||||
),
|
),
|
||||||
getSessionEvents: (id: number, limit = 500) =>
|
getSessionEvents: (id: number, limit = 500, eventTypes?: number[]) => {
|
||||||
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
|
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) =>
|
getSessionKnownWordsTimeline: (id: number) =>
|
||||||
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
|
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
|
||||||
`/api/stats/sessions/${id}/known-words-timeline`,
|
`/api/stats/sessions/${id}/known-words-timeline`,
|
||||||
@@ -142,7 +148,9 @@ export const apiClient = {
|
|||||||
},
|
},
|
||||||
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
||||||
getKnownWordsSummary: () =>
|
getKnownWordsSummary: () =>
|
||||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>('/api/stats/known-words-summary'),
|
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||||
|
'/api/stats/known-words-summary',
|
||||||
|
),
|
||||||
getAnimeKnownWordsSummary: (animeId: number) =>
|
getAnimeKnownWordsSummary: (animeId: number) =>
|
||||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||||
`/api/stats/anime/${animeId}/known-words-summary`,
|
`/api/stats/anime/${animeId}/known-words-summary`,
|
||||||
@@ -200,9 +208,7 @@ export const apiClient = {
|
|||||||
ankiBrowse: async (noteId: number): Promise<void> => {
|
ankiBrowse: async (noteId: number): Promise<void> => {
|
||||||
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
||||||
},
|
},
|
||||||
ankiNotesInfo: async (
|
ankiNotesInfo: async (noteIds: number[]): Promise<StatsAnkiNoteInfo[]> => {
|
||||||
noteIds: number[],
|
|
||||||
): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
WordDetailData,
|
WordDetailData,
|
||||||
KanjiDetailData,
|
KanjiDetailData,
|
||||||
EpisodeDetailData,
|
EpisodeDetailData,
|
||||||
|
StatsAnkiNoteInfo,
|
||||||
} from '../types/stats';
|
} from '../types/stats';
|
||||||
|
|
||||||
interface StatsElectronAPI {
|
interface StatsElectronAPI {
|
||||||
@@ -59,9 +60,7 @@ interface StatsElectronAPI {
|
|||||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||||
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
||||||
ankiBrowse: (noteId: number) => Promise<void>;
|
ankiBrowse: (noteId: number) => Promise<void>;
|
||||||
ankiNotesInfo: (
|
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||||
noteIds: number[],
|
|
||||||
) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
|
|
||||||
hideOverlay: () => void;
|
hideOverlay: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
|||||||
import { EventType } from '../types/stats';
|
import { EventType } from '../types/stats';
|
||||||
import {
|
import {
|
||||||
buildSessionChartEvents,
|
buildSessionChartEvents,
|
||||||
|
collectPendingSessionEventNoteIds,
|
||||||
extractSessionEventNoteInfo,
|
extractSessionEventNoteInfo,
|
||||||
|
getSessionEventCardRequest,
|
||||||
|
mergeSessionEventNoteInfos,
|
||||||
projectSessionMarkerLeftPx,
|
projectSessionMarkerLeftPx,
|
||||||
resolveActiveSessionMarkerKey,
|
resolveActiveSessionMarkerKey,
|
||||||
togglePinnedSessionMarkerKey,
|
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', () => {
|
test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
extractSessionEventNoteInfo({
|
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', () => {
|
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', 'seek-2'), 'seek-2');
|
||||||
assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1');
|
assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1');
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ interface SessionEventNoteField {
|
|||||||
|
|
||||||
interface SessionEventNoteRecord {
|
interface SessionEventNoteRecord {
|
||||||
noteId: unknown;
|
noteId: unknown;
|
||||||
|
preview?: {
|
||||||
|
word?: unknown;
|
||||||
|
sentence?: unknown;
|
||||||
|
translation?: unknown;
|
||||||
|
} | null;
|
||||||
fields?: Record<string, SessionEventNoteField> | null;
|
fields?: Record<string, SessionEventNoteField> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +150,21 @@ export function extractSessionEventNoteInfo(
|
|||||||
return null;
|
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 fields = note.fields ?? {};
|
||||||
const expression = pickExpressionField(fields);
|
const expression = pickExpressionField(fields);
|
||||||
const usedValues = new Set<string>(expression ? [expression] : []);
|
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(
|
export function resolveActiveSessionMarkerKey(
|
||||||
hoveredMarkerKey: string | null,
|
hoveredMarkerKey: string | null,
|
||||||
pinnedMarkerKey: string | null,
|
pinnedMarkerKey: string | null,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
--color-ctp-overlay0: #6e738d;
|
--color-ctp-overlay0: #6e738d;
|
||||||
--color-ctp-blue: #8aadf4;
|
--color-ctp-blue: #8aadf4;
|
||||||
--color-ctp-green: #a6da95;
|
--color-ctp-green: #a6da95;
|
||||||
|
--color-ctp-cards-mined: #f5bde6;
|
||||||
--color-ctp-mauve: #c6a0f6;
|
--color-ctp-mauve: #c6a0f6;
|
||||||
--color-ctp-peach: #f5a97f;
|
--color-ctp-peach: #f5a97f;
|
||||||
--color-ctp-red: #ed8796;
|
--color-ctp-red: #ed8796;
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ export interface SessionEvent {
|
|||||||
payload: string | null;
|
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 {
|
export interface VocabularyEntry {
|
||||||
wordId: number;
|
wordId: number;
|
||||||
headword: string;
|
headword: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user