mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user