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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user