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:
2026-03-18 02:24:38 -07:00
parent a0015dc75c
commit 97126caf4e
23 changed files with 528 additions and 52 deletions

View File

@@ -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;
}
});

View File

@@ -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' },

View File

@@ -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[] = [];

View File

@@ -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;
};
}

View File

@@ -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');

View File

@@ -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,