feat(stats): dashboard updates (#50)

This commit is contained in:
2026-04-10 02:46:50 -07:00
committed by GitHub
parent 9b4de93283
commit 05cf4a6fe5
53 changed files with 5250 additions and 660 deletions
+3 -3
View File
@@ -93,7 +93,7 @@ export function AnimeTab({
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search anime..."
placeholder="Search library..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
@@ -125,12 +125,12 @@ export function AnimeTab({
))}
</div>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} anime · {formatDuration(totalMs)}
{filtered.length} titles · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
) : (
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
{filtered.map((item) => (
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { filterCardEvents } from './EpisodeDetail';
import type { EpisodeCardEvent } from '../../types/stats';
function makeEvent(over: Partial<EpisodeCardEvent> & { eventId: number }): EpisodeCardEvent {
return {
sessionId: 1,
tsMs: 0,
cardsDelta: 1,
noteIds: [],
...over,
};
}
test('filterCardEvents: before load, returns all events unchanged', () => {
const ev1 = makeEvent({ eventId: 1, noteIds: [101] });
const ev2 = makeEvent({ eventId: 2, noteIds: [102] });
const noteInfos = new Map(); // empty — simulates pre-load state
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ false);
assert.equal(result.length, 2, 'should return both events before load');
assert.deepEqual(result[0]?.noteIds, [101]);
assert.deepEqual(result[1]?.noteIds, [102]);
});
test('filterCardEvents: after load, drops noteIds not in noteInfos', () => {
const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); // survives
const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); // deleted from Anki
const noteInfos = new Map([[101, { noteId: 101, expression: '食べる' }]]);
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ true);
assert.equal(result.length, 1, 'should drop event whose noteId was deleted from Anki');
assert.equal(result[0]?.eventId, 1);
assert.deepEqual(result[0]?.noteIds, [101]);
});
test('filterCardEvents: after load, legacy rollup events (empty noteIds, positive cardsDelta) are kept', () => {
const rollup = makeEvent({ eventId: 3, noteIds: [], cardsDelta: 5 });
const noteInfos = new Map<number, { noteId: number; expression: string }>();
const result = filterCardEvents([rollup], noteInfos, true);
assert.equal(result.length, 1, 'legacy rollup event should survive filtering');
assert.equal(result[0]?.cardsDelta, 5);
});
test('filterCardEvents: after load, event with multiple noteIds keeps surviving ones', () => {
const ev = makeEvent({ eventId: 4, noteIds: [201, 202, 203] });
const noteInfos = new Map([
[201, { noteId: 201, expression: 'A' }],
[203, { noteId: 203, expression: 'C' }],
]);
const result = filterCardEvents([ev], noteInfos, true);
assert.equal(result.length, 1, 'event with surviving noteIds should be kept');
assert.deepEqual(result[0]?.noteIds, [201, 203], 'only surviving noteIds should remain');
});
test('filterCardEvents: after load, event where all noteIds deleted is dropped', () => {
const ev = makeEvent({ eventId: 5, noteIds: [301, 302] });
const noteInfos = new Map<number, { noteId: number; expression: string }>();
const result = filterCardEvents([ev], noteInfos, true);
assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped');
});
+47 -3
View File
@@ -16,10 +16,32 @@ interface NoteInfo {
expression: string;
}
export function filterCardEvents(
cardEvents: EpisodeDetailData['cardEvents'],
noteInfos: Map<number, NoteInfo>,
noteInfosLoaded: boolean,
): EpisodeDetailData['cardEvents'] {
if (!noteInfosLoaded) return cardEvents;
return cardEvents
.map((ev) => {
// Legacy rollup events: no noteIds, just a cardsDelta count — keep as-is.
if (ev.noteIds.length === 0) return ev;
const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id));
return { ...ev, noteIds: survivingNoteIds };
})
.filter((ev, i) => {
// If the event originally had noteIds, only keep it if some survived.
if ((cardEvents[i]?.noteIds.length ?? 0) > 0) return ev.noteIds.length > 0;
// Legacy rollup event (originally no noteIds): keep if it has a positive delta.
return ev.cardsDelta > 0;
});
}
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
map.set(note.noteId, { noteId: note.noteId, expression: expr });
}
setNoteInfos(map);
setNoteInfosLoaded(true);
})
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
.catch((err) => {
console.warn('Failed to fetch Anki note info:', err);
if (!cancelled) setNoteInfosLoaded(true);
});
} else {
if (!cancelled) setNoteInfosLoaded(true);
}
})
.catch(() => {
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
const { sessions, cardEvents } = data;
const filteredCardEvents = filterCardEvents(cardEvents, noteInfos, noteInfosLoaded);
const hiddenCardCount = noteInfosLoaded
? cardEvents.reduce((sum, ev) => {
if (ev.noteIds.length === 0) return sum;
const surviving = ev.noteIds.filter((id) => noteInfos.has(id));
return sum + (ev.noteIds.length - surviving.length);
}, 0)
: 0;
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
{sessions.length > 0 && (
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div>
)}
{cardEvents.length > 0 && (
{filteredCardEvents.length > 0 && (
<div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
<div className="space-y-1.5">
{cardEvents.map((ev) => (
{filteredCardEvents.map((ev) => (
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
{ev.noteIds.length > 0 ? (
@@ -144,6 +182,12 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div>
))}
</div>
{hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from
Anki)
</div>
)}
</div>
)}