Files
SubMiner/docs/plans/2026-03-14-episode-detail-anki-link.md
sudacode ee95e86ad5 docs: add stats dashboard design docs, plans, and knowledge base
- 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
2026-03-14 23:11:27 -07:00

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"