Files
SubMiner/docs/plans/2026-03-14-stats-redesign-implementation.md
sudacode cc5d270b8e 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-17 20:01:23 -07:00

34 KiB

Stats Dashboard Redesign — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Restructure the stats dashboard from word-heavy metrics to an anime-centric 5-tab layout (Overview, Anime, Trends, Vocabulary, Sessions).

Architecture: The backend already has anime-level queries (getAnimeLibrary, getAnimeDetail, getAnimeEpisodes) and occurrence queries (getWordOccurrences, getKanjiOccurrences) but no API endpoints for anime. The frontend needs new types, hooks, components, and data builders. Work proceeds bottom-up: backend API → frontend types → hooks → data builders → components.

Tech Stack: Hono (backend API), React + Recharts + Tailwind/Catppuccin (frontend), node:test (backend tests), Vitest (frontend tests)


Task 1: Add Anime API Endpoints to Stats Server

Files:

  • Modify: src/core/services/stats-server.ts
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: query.ts already exports getAnimeLibrary(), getAnimeDetail(db, animeId), getAnimeEpisodes(db, animeId). The stats server needs endpoints to expose them. Also need an anime cover art endpoint — anime links to videos, and videos link to imm_media_art.

Step 1: Write failing tests for the 3 new anime endpoints

Add to src/core/services/__tests__/stats-server.test.ts:

test('GET /api/stats/anime returns anime library', async () => {
  const res = await app.request('/api/stats/anime');
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.ok(Array.isArray(body));
});

test('GET /api/stats/anime/:animeId returns anime detail', async () => {
  // Use animeId from seeded data
  const res = await app.request('/api/stats/anime/1');
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.ok(body.detail);
  assert.ok(Array.isArray(body.episodes));
});

test('GET /api/stats/anime/:animeId returns 404 for missing anime', async () => {
  const res = await app.request('/api/stats/anime/99999');
  assert.equal(res.status, 404);
});

Step 2: Run tests to verify they fail

Run: node --test src/core/services/__tests__/stats-server.test.ts Expected: FAIL — 404 for all anime endpoints

Step 3: Add the anime endpoints to stats-server.ts

Add after the existing media endpoints (around line 165):

app.get('/api/stats/anime', async (c) => {
  const rows = getAnimeLibrary(tracker.db);
  return c.json(rows);
});

app.get('/api/stats/anime/:animeId', async (c) => {
  const animeId = parseIntQuery(c.req.param('animeId'), 0);
  if (animeId <= 0) return c.body(null, 400);
  const detail = getAnimeDetail(tracker.db, animeId);
  if (!detail) return c.body(null, 404);
  const episodes = getAnimeEpisodes(tracker.db, animeId);
  return c.json({ detail, episodes });
});

app.get('/api/stats/anime/:animeId/cover', async (c) => {
  const animeId = parseIntQuery(c.req.param('animeId'), 0);
  if (animeId <= 0) return c.body(null, 404);
  const art = getAnimeCoverArt(tracker.db, animeId);
  if (!art?.coverBlob) return c.body(null, 404);
  return new Response(new Uint8Array(art.coverBlob), {
    headers: {
      'Content-Type': 'image/jpeg',
      'Cache-Control': 'public, max-age=86400',
    },
  });
});

Note: getAnimeCoverArt may need to be added to query.ts — it should look up the first video for the anime and return its cover art. Check if this already exists; if not, add it:

export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
  return db.prepare(`
    SELECT a.video_id, a.anilist_id, a.cover_url, a.cover_blob,
           a.title_romaji, a.title_english, a.episodes_total, a.fetched_at_ms
    FROM imm_media_art a
    JOIN imm_videos v ON v.video_id = a.video_id
    WHERE v.anime_id = ?
    AND a.cover_blob IS NOT NULL
    LIMIT 1
  `).get(animeId) as MediaArtRow | null;
}

Import the new query functions at the top of stats-server.ts.

Step 4: Run tests to verify they pass

Run: node --test src/core/services/__tests__/stats-server.test.ts Expected: All tests PASS

Step 5: Commit

git add src/core/services/stats-server.ts src/core/services/__tests__/stats-server.test.ts src/core/services/immersion-tracker/query.ts
git commit -m "feat(stats): add anime API endpoints to stats server"

Task 2: Add Anime Words and Anime Rollups Query + Endpoints

Files:

  • Modify: src/core/services/immersion-tracker/query.ts
  • Modify: src/core/services/immersion-tracker/types.ts
  • Modify: src/core/services/stats-server.ts
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: The anime detail view needs "top words from this anime" and daily rollups scoped to an anime. Word occurrences already join through imm_word_line_occurrencesimm_subtitle_linesanime_id.

Step 1: Add query functions to query.ts

export interface AnimeWordRow {
  wordId: number;
  headword: string;
  word: string;
  reading: string;
  partOfSpeech: string | null;
  frequency: number;
}

export function getAnimeWords(db: DatabaseSync, animeId: 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.anime_id = ?
    GROUP BY w.id
    ORDER BY frequency DESC
    LIMIT ?
  `).all(animeId, limit) as AnimeWordRow[];
}

export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit = 90): ImmersionSessionRollupRow[] {
  return db.prepare(`
    SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
           r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
           r.total_lines_seen AS totalLinesSeen, r.total_words_seen AS totalWordsSeen,
           r.total_tokens_seen AS totalTokensSeen, r.total_cards AS totalCards,
           r.cards_per_hour AS cardsPerHour, r.words_per_min AS wordsPerMin,
           r.lookup_hit_rate AS lookupHitRate
    FROM imm_daily_rollups r
    JOIN imm_videos v ON v.video_id = r.video_id
    WHERE v.anime_id = ?
    ORDER BY r.rollup_day DESC
    LIMIT ?
  `).all(animeId, limit) as ImmersionSessionRollupRow[];
}

Add AnimeWordRow to the exports in types.ts if needed.

Step 2: Add API endpoints

In stats-server.ts, add:

app.get('/api/stats/anime/:animeId/words', async (c) => {
  const animeId = parseIntQuery(c.req.param('animeId'), 0);
  const limit = parseIntQuery(c.req.query('limit'), 50, 200);
  if (animeId <= 0) return c.body(null, 400);
  return c.json(getAnimeWords(tracker.db, animeId, limit));
});

app.get('/api/stats/anime/:animeId/rollups', async (c) => {
  const animeId = parseIntQuery(c.req.param('animeId'), 0);
  const limit = parseIntQuery(c.req.query('limit'), 90, 365);
  if (animeId <= 0) return c.body(null, 400);
  return c.json(getAnimeDailyRollups(tracker.db, animeId, limit));
});

Step 3: Write tests and verify

Run: node --test src/core/services/__tests__/stats-server.test.ts

Step 4: Commit

git add src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker/types.ts src/core/services/stats-server.ts src/core/services/__tests__/stats-server.test.ts
git commit -m "feat(stats): add anime words and rollups query + endpoints"

Task 3: Extend Overview Endpoint with Episodes and Active Anime

Files:

  • Modify: src/core/services/immersion-tracker/query.ts
  • Modify: src/core/services/stats-server.ts
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: The overview endpoint currently returns { sessions, rollups, hints }. We need to add episodesToday and activeAnimeCount to the hints.

Step 1: Extend getQueryHints in query.ts

The current getQueryHints returns { totalSessions, activeSessions }. Add:

// Episodes today: count distinct video_ids with sessions started today
const today = Math.floor(Date.now() / 86400000);
const episodesToday = (db.prepare(`
  SELECT COUNT(DISTINCT v.video_id) AS count
  FROM imm_sessions s
  JOIN imm_videos v ON v.video_id = s.video_id
  WHERE CAST(s.started_at_ms / 86400000 AS INTEGER) = ?
`).get(today) as { count: number })?.count ?? 0;

// Active anime: anime with sessions in last 30 days
const thirtyDaysAgoMs = Date.now() - 30 * 86400000;
const activeAnimeCount = (db.prepare(`
  SELECT COUNT(DISTINCT v.anime_id) AS count
  FROM imm_sessions s
  JOIN imm_videos v ON v.video_id = s.video_id
  WHERE v.anime_id IS NOT NULL
  AND s.started_at_ms >= ?
`).get(thirtyDaysAgoMs) as { count: number })?.count ?? 0;

Return these as part of the hints object.

Step 2: Write test, run, verify

Run: node --test src/core/services/__tests__/stats-server.test.ts

Step 3: Commit

git add src/core/services/immersion-tracker/query.ts src/core/services/stats-server.ts src/core/services/__tests__/stats-server.test.ts
git commit -m "feat(stats): add episodes today and active anime to overview hints"

Task 4: Extend Vocabulary Endpoint with POS Data

Files:

  • Modify: src/core/services/immersion-tracker/query.ts (function getVocabularyStats)
  • Modify: src/core/services/immersion-tracker/types.ts (VocabularyStatsRow)
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: The vocabulary endpoint currently returns headword, word, reading, frequency, firstSeen, lastSeen. It needs to also return partOfSpeech, pos1, pos2, pos3 and support a ?excludePos=particle query param for filtering.

Step 1: Update VocabularyStatsRow in types.ts

Add fields: partOfSpeech: string | null, pos1: string | null, pos2: string | null, pos3: string | null.

Step 2: Update getVocabularyStats query in query.ts

Add part_of_speech AS partOfSpeech, pos1, pos2, pos3 to the SELECT. Add optional POS filtering parameter.

Step 3: Update the API endpoint in stats-server.ts

Pass the excludePos query param through to the query function.

Step 4: Update frontend type VocabularyEntry in stats/src/types/stats.ts

Add: partOfSpeech: string | null, pos1: string | null, pos2: string | null, pos3: string | null.

Step 5: Test and commit

git commit -m "feat(stats): add POS data to vocabulary endpoint and support filtering"

Task 5: Add Streak Calendar Query + Endpoint

Files:

  • Modify: src/core/services/immersion-tracker/query.ts
  • Modify: src/core/services/immersion-tracker/types.ts
  • Modify: src/core/services/stats-server.ts
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: The streak calendar needs a map of { epochDay → totalActiveMin } for the last 90 days.

Step 1: Add query function

export interface StreakCalendarRow {
  epochDay: number;
  totalActiveMin: number;
}

export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] {
  const cutoffDay = Math.floor(Date.now() / 86400000) - days;
  return db.prepare(`
    SELECT rollup_day AS epochDay, SUM(total_active_min) AS totalActiveMin
    FROM imm_daily_rollups
    WHERE rollup_day >= ?
    GROUP BY rollup_day
    ORDER BY rollup_day ASC
  `).all(cutoffDay) as StreakCalendarRow[];
}

Step 2: Add endpoint

app.get('/api/stats/streak-calendar', async (c) => {
  const days = parseIntQuery(c.req.query('days'), 90, 365);
  return c.json(getStreakCalendar(tracker.db, days));
});

Step 3: Test and commit

git commit -m "feat(stats): add streak calendar endpoint"

Task 6: Add Trend Episode/Anime Queries + Endpoints

Files:

  • Modify: src/core/services/immersion-tracker/query.ts
  • Modify: src/core/services/stats-server.ts
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: Trends tab needs new data: episodes per day, new anime started per day, watch time per anime (stacked).

Step 1: Add query functions

export interface EpisodesPerDayRow {
  epochDay: number;
  episodeCount: number;
}

export function getEpisodesPerDay(db: DatabaseSync, limit = 90): EpisodesPerDayRow[] {
  return db.prepare(`
    SELECT CAST(s.started_at_ms / 86400000 AS INTEGER) AS epochDay,
           COUNT(DISTINCT s.video_id) AS episodeCount
    FROM imm_sessions s
    GROUP BY epochDay
    ORDER BY epochDay DESC
    LIMIT ?
  `).all(limit) as EpisodesPerDayRow[];
}

export interface NewAnimePerDayRow {
  epochDay: number;
  newAnimeCount: number;
}

export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayRow[] {
  return db.prepare(`
    SELECT CAST(MIN(s.started_at_ms) / 86400000 AS INTEGER) AS epochDay,
           COUNT(*) AS newAnimeCount
    FROM (
      SELECT v.anime_id, MIN(s.started_at_ms) AS started_at_ms
      FROM imm_sessions s
      JOIN imm_videos v ON v.video_id = s.video_id
      WHERE v.anime_id IS NOT NULL
      GROUP BY v.anime_id
    ) s
    GROUP BY epochDay
    ORDER BY epochDay DESC
    LIMIT ?
  `).all(limit) as NewAnimePerDayRow[];
}

export interface WatchTimePerAnimeRow {
  epochDay: number;
  animeId: number;
  animeTitle: string;
  totalActiveMin: number;
}

export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePerAnimeRow[] {
  const cutoffDay = Math.floor(Date.now() / 86400000) - limit;
  return db.prepare(`
    SELECT r.rollup_day AS epochDay, a.anime_id AS animeId,
           a.canonical_title AS animeTitle,
           SUM(r.total_active_min) AS totalActiveMin
    FROM imm_daily_rollups r
    JOIN imm_videos v ON v.video_id = r.video_id
    JOIN imm_anime a ON a.anime_id = v.anime_id
    WHERE r.rollup_day >= ?
    GROUP BY r.rollup_day, a.anime_id
    ORDER BY r.rollup_day ASC
  `).all(cutoffDay) as WatchTimePerAnimeRow[];
}

Step 2: Add endpoints

app.get('/api/stats/trends/episodes-per-day', async (c) => {
  const limit = parseIntQuery(c.req.query('limit'), 90, 365);
  return c.json(getEpisodesPerDay(tracker.db, limit));
});

app.get('/api/stats/trends/new-anime-per-day', async (c) => {
  const limit = parseIntQuery(c.req.query('limit'), 90, 365);
  return c.json(getNewAnimePerDay(tracker.db, limit));
});

app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
  const limit = parseIntQuery(c.req.query('limit'), 90, 365);
  return c.json(getWatchTimePerAnime(tracker.db, limit));
});

Step 3: Test and commit

git commit -m "feat(stats): add episode and anime trend query endpoints"

Task 7: Add Word/Kanji Detail Queries + Endpoints

Files:

  • Modify: src/core/services/immersion-tracker/query.ts
  • Modify: src/core/services/stats-server.ts
  • Modify: src/core/services/__tests__/stats-server.test.ts

Context: Clicking a word in the vocab tab should show full detail: POS, frequency, anime appearances, example lines, similar words.

Step 1: Add query functions

export interface WordDetailRow {
  wordId: number;
  headword: string;
  word: string;
  reading: string;
  partOfSpeech: string | null;
  pos1: string | null;
  pos2: string | null;
  pos3: string | null;
  frequency: number;
  firstSeen: number;
  lastSeen: number;
}

export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
  return db.prepare(`
    SELECT id AS wordId, headword, word, reading,
           part_of_speech AS partOfSpeech, pos1, pos2, pos3,
           frequency, first_seen AS firstSeen, last_seen AS lastSeen
    FROM imm_words WHERE id = ?
  `).get(wordId) as WordDetailRow | null;
}

export interface WordAnimeAppearanceRow {
  animeId: number;
  animeTitle: string;
  occurrenceCount: number;
}

export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordAnimeAppearanceRow[] {
  return db.prepare(`
    SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
           SUM(o.occurrence_count) AS occurrenceCount
    FROM imm_word_line_occurrences o
    JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
    JOIN imm_anime a ON a.anime_id = sl.anime_id
    WHERE o.word_id = ?
    GROUP BY a.anime_id
    ORDER BY occurrenceCount DESC
  `).all(wordId) as WordAnimeAppearanceRow[];
}

export interface SimilarWordRow {
  wordId: number;
  headword: string;
  word: string;
  reading: string;
  frequency: number;
}

export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] {
  const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as { headword: string; reading: string } | null;
  if (!word) return [];
  // Words sharing kanji characters or same reading
  return db.prepare(`
    SELECT id AS wordId, headword, word, reading, frequency
    FROM imm_words
    WHERE id != ?
    AND (reading = ? OR headword LIKE ? OR headword LIKE ?)
    ORDER BY frequency DESC
    LIMIT ?
  `).all(
    wordId,
    word.reading,
    `%${word.headword.charAt(0)}%`,
    `%${word.headword.charAt(word.headword.length - 1)}%`,
    limit
  ) as SimilarWordRow[];
}

Add analogous getKanjiDetail, getKanjiAnimeAppearances, getKanjiWords functions.

Step 2: Add endpoints

app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
  const wordId = parseIntQuery(c.req.param('wordId'), 0);
  if (wordId <= 0) return c.body(null, 400);
  const detail = getWordDetail(tracker.db, wordId);
  if (!detail) return c.body(null, 404);
  const animeAppearances = getWordAnimeAppearances(tracker.db, wordId);
  const similarWords = getSimilarWords(tracker.db, wordId);
  return c.json({ detail, animeAppearances, similarWords });
});

app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
  const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0);
  if (kanjiId <= 0) return c.body(null, 400);
  const detail = getKanjiDetail(tracker.db, kanjiId);
  if (!detail) return c.body(null, 404);
  const animeAppearances = getKanjiAnimeAppearances(tracker.db, kanjiId);
  const words = getKanjiWords(tracker.db, kanjiId);
  return c.json({ detail, animeAppearances, words });
});

Step 3: Test and commit

git commit -m "feat(stats): add word and kanji detail endpoints"

Task 8: Update Frontend Types

Files:

  • Modify: stats/src/types/stats.ts

Context: Add all new types needed by the frontend for the redesigned dashboard.

Step 1: Add new types

// Anime types
export interface AnimeLibraryItem {
  animeId: number;
  canonicalTitle: string;
  anilistId: number | null;
  totalSessions: number;
  totalActiveMs: number;
  totalCards: number;
  totalWordsSeen: number;
  episodeCount: number;
  lastWatchedMs: number;
}

export interface AnimeDetailData {
  detail: {
    animeId: number;
    canonicalTitle: string;
    anilistId: number | null;
    titleRomaji: string | null;
    titleEnglish: string | null;
    titleNative: string | null;
    totalSessions: number;
    totalActiveMs: number;
    totalCards: number;
    totalWordsSeen: number;
    totalLinesSeen: number;
    totalLookupCount: number;
    totalLookupHits: number;
    episodeCount: number;
    lastWatchedMs: number;
  };
  episodes: AnimeEpisode[];
}

export interface AnimeEpisode {
  videoId: number;
  parsedEpisode: number | null;
  parsedSeason: number | null;
  canonicalTitle: string;
  totalSessions: number;
  totalActiveMs: number;
  totalCards: number;
  lastWatchedMs: number;
}

export interface AnimeWord {
  wordId: number;
  headword: string;
  word: string;
  reading: string;
  partOfSpeech: string | null;
  frequency: number;
}

// Streak calendar
export interface StreakCalendarDay {
  epochDay: number;
  totalActiveMin: number;
}

// Trend types
export interface EpisodesPerDay {
  epochDay: number;
  episodeCount: number;
}

export interface NewAnimePerDay {
  epochDay: number;
  newAnimeCount: number;
}

export interface WatchTimePerAnime {
  epochDay: number;
  animeId: number;
  animeTitle: string;
  totalActiveMin: number;
}

// Word/Kanji detail
export interface WordDetailData {
  detail: {
    wordId: number;
    headword: string;
    word: string;
    reading: string;
    partOfSpeech: string | null;
    pos1: string | null;
    pos2: string | null;
    pos3: string | null;
    frequency: number;
    firstSeen: number;
    lastSeen: number;
  };
  animeAppearances: Array<{
    animeId: number;
    animeTitle: string;
    occurrenceCount: number;
  }>;
  similarWords: Array<{
    wordId: number;
    headword: string;
    word: string;
    reading: string;
    frequency: number;
  }>;
}

Update VocabularyEntry to include POS fields.

Step 2: Commit

git commit -m "feat(stats): add frontend types for anime-centric dashboard"

Task 9: Update API Client and IPC Client

Files:

  • Modify: stats/src/lib/api-client.ts
  • Modify: stats/src/lib/ipc-client.ts

Context: Both clients need methods for the new endpoints.

Step 1: Add new methods to api-client.ts

getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) => fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) => fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCover: (animeId: number) => `/api/stats/anime/${animeId}/cover`,
getStreakCalendar: (days = 90) => fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) => fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
getNewAnimePerDay: (limit = 90) => fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) => fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getWordDetail: (wordId: number) => fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) => fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),

Mirror the same methods in ipc-client.ts.

Step 2: Commit

git commit -m "feat(stats): add anime and detail methods to API clients"

Task 10: Add Frontend Hooks

Files:

  • Create: stats/src/hooks/useAnimeLibrary.ts
  • Create: stats/src/hooks/useAnimeDetail.ts
  • Create: stats/src/hooks/useStreakCalendar.ts
  • Create: stats/src/hooks/useWordDetail.ts
  • Create: stats/src/hooks/useKanjiDetail.ts

Context: Follow the same pattern as existing hooks (e.g., useMediaLibrary, useMediaDetail). Each hook: fetches on mount or param change, returns { data, loading, error }, handles cleanup.

Step 1: Create hooks

Pattern for each (example useAnimeLibrary):

import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';

export function useAnimeLibrary() {
  const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    const client = getStatsClient();
    client.getAnimeLibrary()
      .then((data) => { if (!cancelled) setAnime(data); })
      .catch((err) => { if (!cancelled) setError(String(err)); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, []);

  return { anime, loading, error };
}

Follow same pattern for useAnimeDetail(animeId), useStreakCalendar(), useWordDetail(wordId), useKanjiDetail(kanjiId).

Step 2: Commit

git commit -m "feat(stats): add frontend hooks for anime, streak, and word detail"

Task 11: Update Dashboard Data Builders

Files:

  • Modify: stats/src/lib/dashboard-data.ts
  • Modify: stats/src/lib/dashboard-data.test.ts

Context: Update buildOverviewSummary to include episodes today and active anime. Add builder for streak calendar data. Extend buildTrendDashboard for new chart series.

Step 1: Update OverviewSummary interface

Add fields: episodesToday: number, activeAnimeCount: number. Remove todayWords, weekWords. Remove recentWords chart data.

Step 2: Update buildOverviewSummary

Pull episodesToday and activeAnimeCount from data.hints.

Step 3: Add streak calendar builder

export interface StreakCalendarPoint {
  date: string;  // YYYY-MM-DD
  value: number; // active minutes
}

export function buildStreakCalendar(days: StreakCalendarDay[]): StreakCalendarPoint[] {
  return days.map(d => ({
    date: epochDayToDate(d.epochDay).toISOString().slice(0, 10),
    value: d.totalActiveMin,
  }));
}

Step 4: Update tests, run, verify

Run: npx vitest run stats/src/lib/dashboard-data.test.ts

Step 5: Commit

git commit -m "feat(stats): update dashboard data builders for anime-centric overview"

Task 12: Update Tab Bar and App Router

Files:

  • Modify: stats/src/App.tsx
  • Modify: stats/src/components/layout/TabBar.tsx

Context: Change tabs from ['overview', 'library', 'trends', 'vocabulary'] to ['overview', 'anime', 'trends', 'vocabulary', 'sessions'].

Step 1: Update TabBar

Change TabId type to 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions'. Update tab labels.

Step 2: Update App.tsx

Replace LibraryTab import with AnimeTab (to be created). Add SessionsTab import. Update conditional rendering.

Note: AnimeTab doesn't exist yet — create a placeholder that renders "Anime tab coming soon" for now. Wire up SessionsTab.

Step 3: Commit

git commit -m "feat(stats): update tab bar to 5-tab anime-centric layout"

Task 13: Redesign Overview Tab

Files:

  • Modify: stats/src/components/overview/OverviewTab.tsx
  • Modify: stats/src/components/overview/HeroStats.tsx
  • Create: stats/src/components/overview/StreakCalendar.tsx

Context: Update hero stats to show the 6 new metrics. Add streak calendar. Remove words chart. Keep watch time chart and recent sessions.

Step 1: Update HeroStats

Change the 6 cards to:

  1. Watch Time Today
  2. Cards Mined Today
  3. Sessions Today
  4. Episodes Today
  5. Current Streak
  6. Active Anime

Step 2: Create StreakCalendar component

GitHub-contributions-style heatmap. 90 days, 7 rows (days of week), colored by intensity (Catppuccin palette: ctp-surface0 for empty, ctp-green shades for activity levels).

Use useStreakCalendar() hook to fetch data.

Step 3: Update OverviewTab layout

  • HeroStats (6 cards)
  • Watch Time Chart (keep)
  • Streak Calendar (new)
  • Tracking Snapshot (updated: total sessions, total episodes, all-time hours, active days, total cards)
  • Recent Sessions (keep, add episode number to display)

Step 4: Commit

git commit -m "feat(stats): redesign overview tab with episodes, streak calendar"

Task 14: Build Anime Tab

Files:

  • Create: stats/src/components/anime/AnimeTab.tsx
  • Create: stats/src/components/anime/AnimeCard.tsx
  • Create: stats/src/components/anime/AnimeDetailView.tsx
  • Create: stats/src/components/anime/AnimeHeader.tsx
  • Create: stats/src/components/anime/EpisodeList.tsx
  • Create: stats/src/components/anime/AnimeWordList.tsx
  • Create: stats/src/components/anime/AnimeCoverImage.tsx

Context: This replaces the Library tab. Reuse patterns from LibraryTab / MediaDetailView but centered on anime_id instead of video_id.

Step 1: AnimeTab (grid view)

  • Search input for filtering by title
  • Sort dropdown: last watched, watch time, cards, progress
  • Responsive grid of AnimeCard components
  • Total count + total watch time header

Step 2: AnimeCard

  • Cover art via AnimeCoverImage (fetches from /api/stats/anime/:animeId/cover)
  • Title, progress bar (episodeCount / episodesTotal), watch time, cards mined
  • Click handler to enter detail view

Step 3: AnimeDetailView

  • AnimeHeader: cover art, titles (romaji/english/native), AniList link, progress bar
  • Stats row: 6 StatCards (watch time, cards, words, lookup rate, sessions, avg session)
  • EpisodeList: table of episodes from AnimeDetailData.episodes
  • Watch time chart using anime rollups
  • AnimeWordList: top words from getAnimeWords, each clickable (opens vocab detail)
  • Mining efficiency chart: cards per hour / cards per episode

Step 4: Commit

git commit -m "feat(stats): build anime tab with grid, detail, episodes, words"

Files:

  • Modify: stats/src/components/trends/TrendsTab.tsx
  • Modify: stats/src/hooks/useTrends.ts

Context: Add the 6 new trend charts. The hook needs to fetch additional data from the new endpoints.

Step 1: Extend useTrends hook

Add fetches for:

  • getEpisodesPerDay(limit)
  • getNewAnimePerDay(limit)
  • getWatchTimePerAnime(limit)

Return these alongside existing data.

Step 2: Add new TrendChart instances to TrendsTab

After the existing 9 charts, add:

  • Episodes per Day (bar chart)
  • Anime Completion Progress (line chart — cumulative)
  • New Anime Started (bar chart)
  • Watch Time per Anime (stacked bar — needs a new StackedTrendChart component or extend TrendChart)
  • Streak History (visual timeline)
  • Cards per Episode (line chart — derive from cards/episodes per day)

For the stacked bar chart, extend TrendChart to accept a stacked prop with multiple data series, or create a StackedTrendChart wrapper.

Step 3: Organize charts into sections

Group visually with section headers: "Activity", "Anime", "Efficiency".

Step 4: Commit

git commit -m "feat(stats): add 6 new trend charts for episodes, anime, efficiency"

Task 16: Redesign Vocabulary Tab

Files:

  • Modify: stats/src/components/vocabulary/VocabularyTab.tsx
  • Modify: stats/src/components/vocabulary/WordList.tsx
  • Modify: stats/src/components/vocabulary/KanjiBreakdown.tsx
  • Modify: stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
  • Create: stats/src/components/vocabulary/WordDetailPanel.tsx
  • Create: stats/src/components/vocabulary/KanjiDetailPanel.tsx

Context: The existing drawer shows occurrence lines. Replace/extend it with a full detail panel showing POS, anime appearances, example lines, similar words.

Step 1: Update VocabularyTab

  • Hero stats: update to use POS-filtered counts (exclude particles)
  • Add POS filter toggle (checkbox to show/hide particles, single-char tokens)
  • Add search input for word/reading search
  • Keep top words chart and new words timeline

Step 2: Update WordList

  • Show POS tag badge next to each word
  • Make each word clickable → opens WordDetailPanel
  • Support POS filtering from parent

Step 3: Create WordDetailPanel

Slide-out panel (reuse VocabularyOccurrencesDrawer pattern):

  • Header: headword, reading, POS (pos1/pos2/pos3)
  • Stats: frequency, first/last seen
  • Anime appearances: list of anime with per-anime frequency (from getWordDetail)
  • Example lines: paginated subtitle lines (from existing getWordOccurrences)
  • Similar words: clickable list (from getWordDetail)

Uses useWordDetail(wordId) hook.

Step 4: Update KanjiBreakdown

Same pattern: clickable kanji → KanjiDetailPanel with frequency, anime appearances, example lines, words using this kanji.

Step 5: Commit

git commit -m "feat(stats): redesign vocabulary tab with POS filter and detail panels"

Task 17: Enhance Sessions Tab

Files:

  • Modify: stats/src/components/sessions/SessionsTab.tsx
  • Modify: stats/src/components/sessions/SessionRow.tsx
  • Modify: stats/src/components/sessions/SessionDetail.tsx

Context: The existing SessionsTab is functional but hidden. Enable it and add anime/episode context.

Step 1: Update SessionRow

  • Add cover art thumbnail (from anime cover endpoint)
  • Show anime title + episode number instead of just canonical title
  • Keep: duration, cards, lines, lookup rate

Step 2: Update SessionsTab

  • Add filter by anime title
  • Add date range filter
  • Group sessions by day (Today / Yesterday / date)

Step 3: Verify SessionDetail still works

The inline expandable detail with timeline chart and events should work as-is.

Step 4: Commit

git commit -m "feat(stats): enhance sessions tab with anime context and filters"

Task 18: Wire Up Cross-Tab Navigation

Files:

  • Modify: stats/src/App.tsx
  • Modify various components

Context: Enable navigation between tabs when clicking related items:

  • Clicking a word in the Anime detail "words from this anime" should navigate to Vocabulary tab and open that word's detail
  • Clicking an anime in the word detail "anime appearances" should navigate to Anime tab and open that anime's detail

Step 1: Lift navigation state to App level

Add state for: selectedAnimeId, selectedWordId. Pass navigation callbacks down to tabs.

Step 2: Wire up cross-references

  • AnimeWordList: onClick navigates to vocabulary tab + opens word detail
  • WordDetailPanel anime appearances: onClick navigates to anime tab + opens anime detail

Step 3: Commit

git commit -m "feat(stats): add cross-tab navigation between anime and vocabulary"

Task 19: Final Integration Testing and Polish

Files:

  • All modified files
  • Modify: stats/src/lib/dashboard-data.test.ts

Step 1: Run all backend tests

Run: node --test src/core/services/__tests__/stats-server.test.ts

Step 2: Run all frontend tests

Run: npx vitest run

Step 3: Build the stats frontend

Run: cd stats && npm run build

Step 4: Visual testing

Start the app and verify each tab renders correctly with real data.

Step 5: Final commit

git commit -m "feat(stats): complete stats dashboard redesign"