- Stats dashboard redesign design and implementation plans - Episode detail and Anki card link design - Internal knowledge base restructure - Backlog tasks for testing, verification, and occurrence tracking
11 KiB
Episode Detail & Anki Card Link Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add expandable episode detail rows with per-episode sessions/words/cards, store Anki note IDs in card mined events, and add "Open in Anki" button that opens the card browser.
Architecture: Extend the recordCardsMined callback chain to pass note IDs alongside count. Store them in the existing payload_json column. Add a stats server endpoint that proxies AnkiConnect's guiBrowse. Frontend makes episode rows expandable with inline detail content.
Tech Stack: Hono (backend API), AnkiConnect (guiBrowse), React + Recharts + Tailwind/Catppuccin (frontend), bun test (backend), Vitest (frontend)
Task 1: Extend recordCardsAdded callback to pass note IDs
Files:
- Modify:
src/anki-integration/anki-connect-proxy.ts - Modify:
src/anki-integration/polling.ts - Modify:
src/anki-integration.ts
Step 1: Update the callback type
In src/anki-integration/anki-connect-proxy.ts line 18, change:
recordCardsAdded?: (count: number) => void;
to:
recordCardsAdded?: (count: number, noteIds: number[]) => void;
In src/anki-integration/polling.ts line 12, same change.
Step 2: Pass note IDs through the proxy callback
In src/anki-integration/anki-connect-proxy.ts line 349, change:
this.deps.recordCardsAdded?.(enqueuedCount);
to:
this.deps.recordCardsAdded?.(enqueuedCount, noteIds.filter(id => !this.pendingNoteIdSet.has(id)));
Wait — the dedup already happened by this point. The noteIds param to enqueueNotes contains the raw IDs. We need the ones that were actually enqueued (not filtered out as duplicates). Track them:
Actually, look at lines 334-348: it iterates noteIds, skips duplicates, and pushes accepted ones to this.pendingNoteIds. The enqueuedCount tracks how many were accepted. We need to collect those IDs:
enqueueNotes(noteIds: number[]): void {
const accepted: number[] = [];
for (const noteId of noteIds) {
if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) {
continue;
}
this.pendingNoteIds.push(noteId);
this.pendingNoteIdSet.add(noteId);
accepted.push(noteId);
}
if (accepted.length > 0) {
this.deps.recordCardsAdded?.(accepted.length, accepted);
}
// ... rest of method
}
Step 3: Pass note IDs through the polling callback
In src/anki-integration/polling.ts line 84, change:
this.deps.recordCardsAdded?.(newNoteIds.length);
to:
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
Step 4: Update AnkiIntegration callback chain
In src/anki-integration.ts:
Line 140, change field type:
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
Line 154, update constructor param:
recordCardsMined?: (count: number, noteIds?: number[]) => void
Lines 214-216 (polling deps), change to:
recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedCallback?.(count, noteIds);
}
Lines 238-240 (proxy deps), same change.
Line 1125-1127 (setter), update signature:
setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void
Step 5: Commit
git commit -m "feat(anki): pass note IDs through recordCardsAdded callback chain"
Task 2: Store note IDs in card mined event payload
Files:
- Modify:
src/core/services/immersion-tracker-service.ts
Step 1: Update recordCardsMined to accept and store note IDs
Find the recordCardsMined method (line 759). Change signature and payload:
recordCardsMined(count = 1, noteIds?: number[]): void {
if (!this.sessionState) return;
this.sessionState.cardsMined += count;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_CARD_MINED,
wordsDelta: 0,
cardsDelta: count,
payloadJson: sanitizePayload(
{ cardsMined: count, ...(noteIds?.length ? { noteIds } : {}) },
this.maxPayloadBytes,
),
});
}
Step 2: Update the caller in main.ts
Find where recordCardsMined is called (around line 2506-2508 and 3409-3411). Pass through noteIds:
recordCardsMined: (count, noteIds) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
}
Step 3: Commit
git commit -m "feat(immersion): store anki note IDs in card mined event payload"
Task 3: Add episode-level query functions
Files:
- Modify:
src/core/services/immersion-tracker/query.ts - Modify:
src/core/services/immersion-tracker/types.ts - Modify:
src/core/services/immersion-tracker-service.ts
Step 1: Add types
In types.ts, add:
export interface EpisodeCardEventRow {
eventId: number;
sessionId: number;
tsMs: number;
cardsDelta: number;
noteIds: number[];
}
Step 2: Add query functions
In query.ts:
export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): AnimeWordRow[] {
return db.prepare(`
SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech,
SUM(o.occurrence_count) AS frequency
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
JOIN imm_words w ON w.id = o.word_id
WHERE sl.video_id = ?
GROUP BY w.id
ORDER BY frequency DESC
LIMIT ?
`).all(videoId, limit) as unknown as AnimeWordRow[];
}
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
return db.prepare(`
SELECT
s.session_id AS sessionId, s.video_id AS videoId,
v.canonical_title AS canonicalTitle,
s.started_at_ms AS startedAtMs, s.ended_at_ms AS endedAtMs,
COALESCE(MAX(t.total_watched_ms), 0) AS totalWatchedMs,
COALESCE(MAX(t.active_watched_ms), 0) AS activeWatchedMs,
COALESCE(MAX(t.lines_seen), 0) AS linesSeen,
COALESCE(MAX(t.words_seen), 0) AS wordsSeen,
COALESCE(MAX(t.tokens_seen), 0) AS tokensSeen,
COALESCE(MAX(t.cards_mined), 0) AS cardsMined,
COALESCE(MAX(t.lookup_count), 0) AS lookupCount,
COALESCE(MAX(t.lookup_hits), 0) AS lookupHits
FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id
LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id
WHERE s.video_id = ?
GROUP BY s.session_id
ORDER BY s.started_at_ms DESC
`).all(videoId) as SessionSummaryQueryRow[];
}
export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] {
const rows = db.prepare(`
SELECT e.event_id AS eventId, e.session_id AS sessionId,
e.ts_ms AS tsMs, e.cards_delta AS cardsDelta,
e.payload_json AS payloadJson
FROM imm_session_events e
JOIN imm_sessions s ON s.session_id = e.session_id
WHERE s.video_id = ? AND e.event_type = 4
ORDER BY e.ts_ms DESC
`).all(videoId) as Array<{ eventId: number; sessionId: number; tsMs: number; cardsDelta: number; payloadJson: string | null }>;
return rows.map(row => {
let noteIds: number[] = [];
if (row.payloadJson) {
try {
const parsed = JSON.parse(row.payloadJson);
if (Array.isArray(parsed.noteIds)) noteIds = parsed.noteIds;
} catch {}
}
return { eventId: row.eventId, sessionId: row.sessionId, tsMs: row.tsMs, cardsDelta: row.cardsDelta, noteIds };
});
}
Step 3: Add wrapper methods to immersion-tracker-service.ts
Step 4: Commit
git commit -m "feat(stats): add episode-level query functions for sessions, words, cards"
Task 4: Add episode detail and Anki browse API endpoints
Files:
- Modify:
src/core/services/stats-server.ts - Modify:
src/core/services/__tests__/stats-server.test.ts
Step 1: Add episode detail endpoint
app.get('/api/stats/episode/:videoId/detail', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const sessions = await tracker.getEpisodeSessions(videoId);
const words = await tracker.getEpisodeWords(videoId);
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
return c.json({ sessions, words, cardEvents });
});
Step 2: Add Anki browse endpoint
app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400);
try {
const response = await fetch('http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }),
});
const result = await response.json();
return c.json(result);
} catch (err) {
return c.json({ error: 'Failed to reach AnkiConnect' }, 502);
}
});
Step 3: Add tests and verify
Run: bun test ./src/core/services/__tests__/stats-server.test.ts
Step 4: Commit
git commit -m "feat(stats): add episode detail and anki browse endpoints"
Task 5: Add frontend types and API client methods
Files:
- Modify:
stats/src/types/stats.ts - Modify:
stats/src/lib/api-client.ts - Modify:
stats/src/lib/ipc-client.ts
Step 1: Add types
export interface EpisodeCardEvent {
eventId: number;
sessionId: number;
tsMs: number;
cardsDelta: number;
noteIds: number[];
}
export interface EpisodeDetailData {
sessions: SessionSummary[];
words: AnimeWord[];
cardEvents: EpisodeCardEvent[];
}
Step 2: Add API client methods
getEpisodeDetail: (videoId: number) => fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
ankiBrowse: (noteId: number) => fetchJson<unknown>(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' }),
Mirror in ipc-client.
Step 3: Commit
git commit -m "feat(stats): add episode detail types and API client methods"
Task 6: Build EpisodeDetail component
Files:
- Create:
stats/src/components/anime/EpisodeDetail.tsx - Modify:
stats/src/components/anime/EpisodeList.tsx - Modify:
stats/src/components/anime/AnimeCardsList.tsx
Step 1: Create EpisodeDetail component
Inline expandable content showing:
- Sessions list (compact: time, duration, cards, words)
- Cards mined list with "Open in Anki" button per note ID
- Top words grid (reuse AnimeWordList pattern)
Fetches data from getEpisodeDetail(videoId) on mount.
"Open in Anki" button calls apiClient.ankiBrowse(noteId).
Step 2: Make EpisodeList rows expandable
Add expandedVideoId state. Clicking a row toggles expansion. Render EpisodeDetail below the expanded row.
Step 3: Make AnimeCardsList rows expandable
Same pattern — clicking an episode row expands to show EpisodeDetail.
Step 4: Commit
git commit -m "feat(stats): add expandable episode detail with anki card links"
Task 7: Build and verify
Step 1: Type check
Run: npx tsc --noEmit
Step 2: Run backend tests
Run: bun test ./src/core/services/__tests__/stats-server.test.ts
Step 3: Run frontend tests
Run: npx vitest run
Step 4: Build
Run: npx vite build
Step 5: Commit any fixes
git commit -m "feat(stats): episode detail and anki link complete"