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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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