# Stats Dashboard Feedback Pass Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Land seven UX/correctness improvements to the SubMiner stats dashboard in a single PR with one logical commit per task. **Architecture:** All work lives in `stats/src/` (React + Vite + Tailwind UI) and `src/core/services/immersion-tracker/` (sqlite-backed query layer for the 365d range only). No schema changes, no migrations. Each task is independently testable; helpers get their own files with focused unit tests. **Tech Stack:** Bun, TypeScript, React 19, Recharts, Tailwind, sqlite via `node:sqlite`, `bun:test`. **Spec:** `docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md` --- ## Working agreements - One commit per task. Commit message conventions follow recent history (`feat(stats):` / `fix(stats):` / `docs:`). - Run `bun run typecheck` after every commit; run `bun run typecheck:stats` after stats UI commits. - Use Bun, not Node: `bun test `, not `npx jest`. - Ranges or files cited as `path:line` are valid as of branch `stats-update`. If something has moved, re-grep before editing. - Tests for stats UI files live alongside their source (e.g. `LibraryTab.tsx` → `LibraryTab.test.tsx`). Use `bun test stats/src/path/to/file.test.tsx` to run a single file. - Frequently committing means: each task is its own commit. Don't squash. ## Pre-flight (do this once before Task 1) - [ ] **Step 1: Verify branch and clean tree** Run: `git status && git log --oneline -3` Expected: branch `stats-update`, working tree clean except for whatever you're about to do, last commit is the spec commit `82d58a57`. - [ ] **Step 2: Verify baseline tests pass** Run: `bun run typecheck && bun run typecheck:stats` Expected: both succeed. - [ ] **Step 3: Confirm bun test runs single stats files** Run: `bun test stats/src/lib/api-client.test.ts` Expected: tests pass. --- ## Task 1: 365d range — backend type extension **Files:** - Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88` - Test: `src/core/services/immersion-tracker/__tests__/query.test.ts` - [ ] **Step 1: Read the existing range table test** Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t '365d' 2>&1 | head -20` Expected: no test named `365d`. Locate existing range coverage by reading the file (`grep -n '7d\|30d\|90d' src/core/services/immersion-tracker/__tests__/query.test.ts`) so you can mirror its style. - [ ] **Step 2: Add a failing test that asserts `365d` returns up to 365 day buckets** Edit `src/core/services/immersion-tracker/__tests__/query.test.ts`. Find an existing trend range test (search for `'90d'`) and add a new sibling test that: - Seeds 400 days of synthetic daily activity (or whatever the existing helpers use). - Calls the trends query with `range: '365d', groupBy: 'day'`. - Asserts the returned `watchTimeByDay.length === 365`. - Mirrors the assertions style of the existing 90d test exactly. - [ ] **Step 3: Run the new test to verify it fails** Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t '365d'` Expected: TypeScript compile error or runtime failure because `'365d'` is not assignable to `TrendRange`. - [ ] **Step 4: Extend `TrendRange` and `TREND_DAY_LIMITS`** In `src/core/services/immersion-tracker/query-trends.ts`: - Line 16: change to `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';` - Line 84-88: add `'365d': 365,` so the map becomes: ```ts const TREND_DAY_LIMITS: Record, number> = { '7d': 7, '30d': 30, '90d': 90, '365d': 365, }; ``` - [ ] **Step 5: Run the new test to verify it passes** Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t '365d'` Expected: PASS. - [ ] **Step 6: Run the full query test file to verify no regressions** Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` Expected: all tests pass. - [ ] **Step 7: Commit** ```bash git add src/core/services/immersion-tracker/query-trends.ts \ src/core/services/immersion-tracker/__tests__/query.test.ts git commit -m "feat(stats): support 365d range in trends query" ``` --- ## Task 2: 365d range — server route allow-list **Files:** - Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`) - Test: `src/core/services/__tests__/stats-server.test.ts` - [ ] **Step 1: Locate the trends route in `stats-server.ts`** Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts` Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue. - [ ] **Step 2: Add a failing test for `range=365d`** In `src/core/services/__tests__/stats-server.test.ts`, find the existing trends route test (search for `'/api/stats/trends'`). Add a sibling case that issues a request with `range=365d` and asserts the response is 200 (not 400). - [ ] **Step 3: Run the test to verify it fails** Run: `bun test src/core/services/__tests__/stats-server.test.ts -t '365d'` Expected: FAIL because `365d` isn't in the allow-list. - [ ] **Step 4: Extend the allow-list** Add `'365d'` to the `validRanges`/`allowedRanges` array (whatever it is named) so it sits next to `'90d'`. - [ ] **Step 5: Re-run the test** Run: `bun test src/core/services/__tests__/stats-server.test.ts -t '365d'` Expected: PASS. - [ ] **Step 6: Run the full server test file** Run: `bun test src/core/services/__tests__/stats-server.test.ts` Expected: all tests pass. - [ ] **Step 7: Commit (only if step 1 found an allow-list)** ```bash git add src/core/services/stats-server.ts \ src/core/services/__tests__/stats-server.test.ts git commit -m "feat(stats): allow 365d trends range in HTTP route" ``` --- ## Task 3: 365d range — frontend client and selector **Files:** - Modify: `stats/src/lib/api-client.ts` - Modify: `stats/src/lib/api-client.test.ts` - Modify: `stats/src/hooks/useTrends.ts:5` - Modify: `stats/src/components/trends/DateRangeSelector.tsx:56` - [ ] **Step 1: Locate range usage in the api-client** Run: `grep -n 'TrendRange\|range\|7d\|90d' stats/src/lib/api-client.ts | head -20` Identify whether the client validates ranges or simply passes them through. Mirror your test/edit accordingly. - [ ] **Step 2: Add a failing test in `api-client.test.ts`** Add a test case that calls `apiClient.getTrendsDashboard('365d', 'day')` (or whatever the public method is named), stubs `fetch`, and asserts the URL contains `range=365d`. Mirror the existing 90d test if there is one. - [ ] **Step 3: Run the new test to verify it fails** Run: `bun test stats/src/lib/api-client.test.ts -t '365d'` Expected: FAIL on type-narrowing. - [ ] **Step 4: Widen the client `TrendRange` union** In `stats/src/lib/api-client.ts`, find any `TrendRange`-shaped union and add `'365d'`. If the client re-imports the type from elsewhere, no edit needed beyond the consumer test. - [ ] **Step 5: Update `useTrends.ts:5`** Change `export type TimeRange = '7d' | '30d' | '90d' | 'all';` to `export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';`. - [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control** In `stats/src/components/trends/DateRangeSelector.tsx:56`, change: ```tsx options={['7d', '30d', '90d', 'all'] as TimeRange[]} ``` to: ```tsx options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]} ``` - [ ] **Step 7: Run the new client test** Run: `bun test stats/src/lib/api-client.test.ts -t '365d'` Expected: PASS. - [ ] **Step 8: Typecheck the stats UI** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 9: Commit** ```bash git add stats/src/lib/api-client.ts stats/src/lib/api-client.test.ts \ stats/src/hooks/useTrends.ts stats/src/components/trends/DateRangeSelector.tsx git commit -m "feat(stats): expose 365d trends range in dashboard UI" ``` --- ## Task 4: Vocabulary Top 50 — collapse word/reading column **Files:** - Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144` - Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`) - [ ] **Step 1: Check whether a test file exists** Run: `ls stats/src/components/vocabulary/FrequencyRankTable.test.tsx 2>/dev/null || echo "missing"` If missing, you'll create it in step 2. - [ ] **Step 2: Write the failing test** Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with: ```tsx import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'bun:test'; import { FrequencyRankTable } from './FrequencyRankTable'; import type { VocabularyEntry } from '../../types/stats'; function makeEntry(over: Partial): VocabularyEntry { return { wordId: 1, headword: '日本語', word: '日本語', reading: 'にほんご', frequency: 5, frequencyRank: 100, animeCount: 1, partOfSpeech: null, firstSeen: 0, lastSeen: 0, ...over, } as VocabularyEntry; } describe('FrequencyRankTable', () => { it('renders headword and reading inline in a single column (no separate Reading header)', () => { const entry = makeEntry({}); render(); // Reading should be visually associated with the headword, not in its own column. expect(screen.queryByRole('columnheader', { name: 'Reading' })).toBeNull(); expect(screen.getByText('日本語')).toBeTruthy(); expect(screen.getByText(/にほんご/)).toBeTruthy(); }); it('omits reading when reading equals headword', () => { const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' }); render(); // Headword still renders; no bracketed reading line for the duplicate. expect(screen.getByText('カレー')).toBeTruthy(); expect(screen.queryByText(/【カレー】/)).toBeNull(); }); }); ``` Note: this assumes `@testing-library/react` is already a dev dep — confirm with `grep '@testing-library/react' stats/package.json /Users/sudacode/projects/japanese/SubMiner/package.json`. If it's not in `stats/`, run the test with `bun test` from repo root since tooling may resolve from the parent. If the project's existing component tests use a different render helper (check `MediaDetailView.test.tsx` for the pattern), copy that pattern instead. - [ ] **Step 3: Run the new test to verify it fails** Run: `bun test stats/src/components/vocabulary/FrequencyRankTable.test.tsx` Expected: FAIL — the current component still renders a Reading ``. - [ ] **Step 4: Modify `FrequencyRankTable.tsx`** Replace the `Reading` header column and the corresponding `` in the body. The new shape: Header (around line 113-119): ```tsx Rank Word POS Seen ``` Body row (around line 122-141): ```tsx onSelectWord?.(w)} className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors" > #{w.frequencyRank!.toLocaleString()} {w.headword} {(() => { const reading = fullReading(w.headword, w.reading); if (!reading || reading === w.headword) return null; return ( 【{reading}】 ); })()} {w.partOfSpeech && } {w.frequency}x ``` - [ ] **Step 5: Run the test to verify it passes** Run: `bun test stats/src/components/vocabulary/FrequencyRankTable.test.tsx` Expected: PASS. - [ ] **Step 6: Typecheck** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 7: Commit** ```bash git add stats/src/components/vocabulary/FrequencyRankTable.tsx \ stats/src/components/vocabulary/FrequencyRankTable.test.tsx git commit -m "fix(stats): collapse word and reading into one column in Top 50 table" ``` --- ## Task 5: Episode detail — filter Anki-deleted cards **Files:** - Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147` - Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present - [ ] **Step 1: Confirm `ankiNotesInfo` is only consumed in `EpisodeDetail.tsx`** Run: `grep -rn 'ankiNotesInfo' stats/src` Expected: only `EpisodeDetail.tsx`. If anything else turns up, this task must also patch that consumer. - [ ] **Step 2: Write the failing test** Create `stats/src/components/anime/EpisodeDetail.test.tsx` (copy the import/setup pattern from the closest existing component test like `MediaDetailView.test.tsx`): ```tsx import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, mock, beforeEach } from 'bun:test'; import { EpisodeDetail } from './EpisodeDetail'; // Mock the stats client. Mirror the mocking style used in MediaDetailView.test.tsx. // The key behavior: ankiNotesInfo only returns one of the two requested noteIds. describe('EpisodeDetail card filtering', () => { beforeEach(() => { // reset mocks }); it('hides card events whose Anki notes have been deleted', async () => { // Stub getStatsClient().getEpisodeDetail to return two cardEvents, // each with one noteId. // Stub ankiNotesInfo to return only the first noteId. // Render . // Wait for the cards to load. // Assert exactly one card row is visible. // Assert the surviving event's expression renders. }); }); ``` Implementation note: the existing `EpisodeDetail.tsx` calls `getStatsClient()` directly. Look at how `MediaDetailView.test.tsx` handles this — there's already an established mocking pattern. Copy it. If it uses `mock.module('../../hooks/useStatsApi', ...)`, do the same. - [ ] **Step 3: Run the test to verify it fails** Run: `bun test stats/src/components/anime/EpisodeDetail.test.tsx` Expected: FAIL — both card rows currently render even when one note is missing. - [ ] **Step 4: Add the filter to `EpisodeDetail.tsx`** At the top of the render section (after `const { sessions, cardEvents } = data;` around line 73), insert: ```tsx const filteredCardEvents = cardEvents .map((ev) => { if (ev.noteIds.length === 0) { // Legacy rollup events with no noteIds — leave alone. return ev; } const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id)); return { ...ev, noteIds: survivingNoteIds }; }) .filter((ev) => { // Drop events that originally had noteIds but lost them all. return ev.noteIds.length > 0 || ev.cardsDelta > 0; }); // Track how many were hidden so we can surface a small footer. const hiddenCardCount = cardEvents.reduce((acc, ev) => { if (ev.noteIds.length === 0) return acc; const dropped = ev.noteIds.filter((id) => !noteInfos.has(id)).length; return acc + dropped; }, 0); ``` Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `` closing the cards-mined section, add: ```tsx {hiddenCardCount > 0 && (
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
)} ``` Place that footer immediately before the closing `` of the bordered cards-mined section, so it stays scoped to that block. **Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`. Concrete change near line 22: ```tsx const [noteInfos, setNoteInfos] = useState>(new Map()); const [noteInfosLoaded, setNoteInfosLoaded] = useState(false); ``` Inside the existing `useEffect` (around line 36-46), set the loaded flag: ```tsx if (allNoteIds.length > 0) { getStatsClient() .ankiNotesInfo(allNoteIds) .then((notes) => { if (cancelled) return; const map = new Map(); for (const note of notes) { const expr = note.preview?.word ?? ''; map.set(note.noteId, { noteId: note.noteId, expression: expr }); } setNoteInfos(map); setNoteInfosLoaded(true); }) .catch((err) => { console.warn('Failed to fetch Anki note info:', err); if (!cancelled) setNoteInfosLoaded(true); // unblock so we don't hide everything }); } else { setNoteInfosLoaded(true); } ``` And gate the filter: ```tsx const filteredCardEvents = noteInfosLoaded ? cardEvents .map((ev) => { if (ev.noteIds.length === 0) return ev; const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id)); return { ...ev, noteIds: survivingNoteIds }; }) .filter((ev) => ev.noteIds.length > 0 || ev.cardsDelta > 0) : cardEvents; ``` - [ ] **Step 5: Run the test to verify it passes** Run: `bun test stats/src/components/anime/EpisodeDetail.test.tsx` Expected: PASS. - [ ] **Step 6: Add a second test for the loading-state guard** Extend the test to assert that, before `ankiNotesInfo` resolves, both card rows still appear (so we don't briefly flash an empty list). Then verify that after resolution, the deleted one disappears. - [ ] **Step 7: Run both tests** Run: `bun test stats/src/components/anime/EpisodeDetail.test.tsx` Expected: PASS. - [ ] **Step 8: Typecheck** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 9: Commit** ```bash git add stats/src/components/anime/EpisodeDetail.tsx \ stats/src/components/anime/EpisodeDetail.test.tsx git commit -m "fix(stats): hide cards deleted from Anki in episode detail" ``` --- ## Task 6: Library detail — delete episode action **Files:** - Modify: `stats/src/components/library/MediaHeader.tsx` - Modify: `stats/src/components/library/MediaDetailView.tsx` - Modify: `stats/src/hooks/useMediaLibrary.ts` - Modify: `stats/src/components/library/LibraryTab.tsx` - Test: extend `stats/src/components/library/MediaDetailView.test.tsx` - Test: extend or create `stats/src/hooks/useMediaLibrary.test.ts` - [ ] **Step 1: Add a failing test for the delete button in `MediaDetailView.test.tsx`** Read `stats/src/components/library/MediaDetailView.test.tsx` first to see the test scaffolding. Then add a new test: ```tsx it('deletes the episode and calls onBack when the delete button is clicked', async () => { const onBack = mock(() => {}); const deleteVideo = mock(async () => {}); // Stub apiClient.deleteVideo with the mock above (mirror existing stub patterns). // Stub useMediaDetail to return a populated detail object. // Stub window.confirm to return true. render(); // Wait for the header to render. const button = await screen.findByRole('button', { name: /delete episode/i }); button.click(); await waitFor(() => expect(deleteVideo).toHaveBeenCalledWith(42)); await waitFor(() => expect(onBack).toHaveBeenCalled()); }); ``` - [ ] **Step 2: Run the failing test** Run: `bun test stats/src/components/library/MediaDetailView.test.tsx -t 'delete'` Expected: FAIL because no delete button exists. - [ ] **Step 3: Add `onDeleteEpisode` prop to `MediaHeader`** In `stats/src/components/library/MediaHeader.tsx`: ```tsx interface MediaHeaderProps { detail: NonNullable; initialKnownWordsSummary?: { totalUniqueWords: number; knownWordCount: number; } | null; onDeleteEpisode?: () => void; } export function MediaHeader({ detail, initialKnownWordsSummary = null, onDeleteEpisode, }: MediaHeaderProps) { ``` Inside the right-hand `
`, immediately after the `

` title row, add a flex container so the delete button can sit on the far right of the header. Easier: put the button at the top-right of the title row by wrapping the title in a flex layout: ```tsx

{detail.canonicalTitle}

{onDeleteEpisode && ( )}
``` - [ ] **Step 4: Wire `onDeleteEpisode` in `MediaDetailView.tsx`** Add a handler near the existing `handleDeleteSession`: ```tsx const handleDeleteEpisode = async () => { const title = data.detail.canonicalTitle; if (!confirmEpisodeDelete(title)) return; setDeleteError(null); try { await apiClient.deleteVideo(videoId); onBack(); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.'); } }; ``` Add `confirmEpisodeDelete` to the existing `delete-confirm` import line. Pass the handler down: ``. - [ ] **Step 5: Run the test to verify it passes** Run: `bun test stats/src/components/library/MediaDetailView.test.tsx -t 'delete'` Expected: PASS. - [ ] **Step 6: Add `refresh` to `useMediaLibrary`** In `stats/src/hooks/useMediaLibrary.ts`, hoist the `load` function out of `useEffect` using `useCallback`, and return a `refresh` function: ```tsx import { useState, useEffect, useCallback } from 'react'; export function useMediaLibrary() { const [media, setMedia] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [version, setVersion] = useState(0); const refresh = useCallback(() => setVersion((v) => v + 1), []); useEffect(() => { let cancelled = false; let retryCount = 0; let retryTimer: ReturnType | null = null; const load = (isInitial = false) => { if (isInitial) { setLoading(true); setError(null); } getStatsClient() .getMediaLibrary() .then((rows) => { if (cancelled) return; setMedia(rows); if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) { retryCount += 1; retryTimer = setTimeout(() => { retryTimer = null; load(false); }, MEDIA_LIBRARY_REFRESH_DELAY_MS); } }) .catch((err: Error) => { if (cancelled) return; setError(err.message); }) .finally(() => { if (cancelled || !isInitial) return; setLoading(false); }); }; load(true); return () => { cancelled = true; if (retryTimer) { clearTimeout(retryTimer); } }; }, [version]); return { media, loading, error, refresh }; } ``` - [ ] **Step 7: Add a focused test for `refresh`** In `stats/src/hooks/useMediaLibrary.test.ts`, add a test that: - Mounts the hook with `renderHook` from `@testing-library/react`. - Asserts `getMediaLibrary` was called once. - Calls `result.current.refresh()` inside `act`. - Asserts `getMediaLibrary` was called twice. If the file doesn't have `renderHook` patterns, mirror whichever helper the existing tests use. Look at the existing test file first. - [ ] **Step 8: Wire `refresh` from `LibraryTab.tsx`** In `stats/src/components/library/LibraryTab.tsx`: ```tsx const { media, loading, error, refresh } = useMediaLibrary(); ``` And update the early-return that mounts the detail view: ```tsx if (selectedVideoId !== null) { return ( { setSelectedVideoId(null); refresh(); }} /> ); } ``` - [ ] **Step 9: Run the new tests** Run: `bun test stats/src/hooks/useMediaLibrary.test.ts && bun test stats/src/components/library/MediaDetailView.test.tsx` Expected: PASS. - [ ] **Step 10: Typecheck** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 11: Commit** ```bash git add stats/src/components/library/MediaHeader.tsx \ stats/src/components/library/MediaDetailView.tsx \ stats/src/components/library/MediaDetailView.test.tsx \ stats/src/components/library/LibraryTab.tsx \ stats/src/hooks/useMediaLibrary.ts \ stats/src/hooks/useMediaLibrary.test.ts git commit -m "feat(stats): delete episode from library detail view" ``` --- ## Task 7: Library — collapsible series groups **Files:** - Modify: `stats/src/components/library/LibraryTab.tsx` - Test: create `stats/src/components/library/LibraryTab.test.tsx` - [ ] **Step 1: Write the failing tests** Create `stats/src/components/library/LibraryTab.test.tsx`. Mirror the import/mocking pattern from `MediaDetailView.test.tsx`. Stub `useMediaLibrary` to return: - One group with three videos (multi-video series). - One group with one video (singleton). Add three tests: ```tsx it('renders the multi-video group collapsed by default', async () => { // Render LibraryTab with stubbed hook. // Assert: the group header is visible. // Assert: the three video MediaCards are NOT in the DOM (collapsed). }); it('renders the single-video group expanded by default', async () => { // Assert: the singleton's MediaCard IS in the DOM. }); it('toggles the collapsed group when its header is clicked', async () => { // Click the multi-video group header. // Assert: the three MediaCards now appear. // Click again. // Assert: they disappear. }); ``` How to identify cards: each `MediaCard` should expose its title via the cover image alt text or a title element. Use `screen.queryAllByText()` to count them. - [ ] **Step 2: Run the failing tests** Run: `bun test stats/src/components/library/LibraryTab.test.tsx` Expected: FAIL — current `LibraryTab` always shows all cards. - [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`** Modify imports: ```tsx import { useState, useMemo, useCallback } from 'react'; ``` Inside the component, after the existing `useState` calls: ```tsx const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set()); // When grouped data changes, default-collapse groups with >1 video. // We do this declaratively in a useMemo to keep state derived. const effectiveCollapsed = useMemo(() => { const next = new Set(collapsedGroups); for (const group of grouped) { // Only auto-collapse on first encounter; if user has interacted, leave alone. // We do this by tracking which keys we've seen via a ref. Simpler approach: // initialize on mount via useEffect below. } return next; }, [collapsedGroups, grouped]); ``` Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**: ```tsx const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set()); const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false); useEffect(() => { if (hasInitializedCollapsed || grouped.length === 0) return; const initial = new Set<string>(); for (const group of grouped) { if (group.items.length > 1) initial.add(group.key); } setCollapsedGroups(initial); setHasInitializedCollapsed(true); }, [grouped, hasInitializedCollapsed]); const toggleGroup = useCallback((key: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }, []); ``` Don't forget to add `useEffect` to the import line. - [ ] **Step 4: Update the group rendering** Replace the section block (around line 64-115) so the header is a `<button>`: ```tsx {grouped.map((group) => { const isCollapsed = collapsedGroups.has(group.key); const isSingleVideo = group.items.length === 1; return ( <section key={group.key} className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden" > <button type="button" onClick={() => !isSingleVideo && toggleGroup(group.key)} aria-expanded={!isCollapsed} aria-controls={`group-body-${group.key}`} disabled={isSingleVideo} className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${ isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer' }`} > {!isSingleVideo && ( <span aria-hidden="true" className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${ isCollapsed ? '' : 'rotate-90' }`} > {'\u25B6'} </span> )} <CoverImage videoId={group.items[0]!.videoId} title={group.title} src={group.imageUrl} className="w-16 h-16 rounded-2xl shrink-0" /> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2"> <h3 className="text-base font-semibold text-ctp-text truncate"> {group.title} </h3> </div> {group.subtitle ? ( <div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div> ) : null} <div className="text-xs text-ctp-overlay2 mt-2"> {group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '} {formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards </div> </div> </button> {!isCollapsed && ( <div id={`group-body-${group.key}`} className="p-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> {group.items.map((item) => ( <MediaCard key={item.videoId} item={item} onClick={() => setSelectedVideoId(item.videoId)} /> ))} </div> </div> )} </section> ); })} ``` **Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption. - [ ] **Step 5: Run the tests to verify they pass** Run: `bun test stats/src/components/library/LibraryTab.test.tsx` Expected: PASS. - [ ] **Step 6: Typecheck** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 7: Commit** ```bash git add stats/src/components/library/LibraryTab.tsx \ stats/src/components/library/LibraryTab.test.tsx git commit -m "feat(stats): collapsible series groups in library tab" ``` --- ## Task 8: Session grouping helper **Files:** - Create: `stats/src/lib/session-grouping.ts` - Create: `stats/src/lib/session-grouping.test.ts` - [ ] **Step 1: Write the failing tests** Create `stats/src/lib/session-grouping.test.ts`: ```ts import { describe, it, expect } from 'bun:test'; import { groupSessionsByVideo } from './session-grouping'; import type { SessionSummary } from '../types/stats'; function makeSession(over: Partial<SessionSummary>): SessionSummary { return { sessionId: 1, videoId: 100, canonicalTitle: 'Episode 1', animeTitle: 'Show', startedAtMs: 1_000_000, activeWatchedMs: 60_000, cardsMined: 1, linesSeen: 10, lookupCount: 5, lookupHits: 3, knownWordsSeen: 5, // Add any other required fields by reading types/stats.ts. ...over, } as SessionSummary; } describe('groupSessionsByVideo', () => { it('returns an empty array for empty input', () => { expect(groupSessionsByVideo([])).toEqual([]); }); it('emits a singleton bucket for unique videoIds', () => { const a = makeSession({ sessionId: 1, videoId: 100 }); const b = makeSession({ sessionId: 2, videoId: 200 }); const buckets = groupSessionsByVideo([a, b]); expect(buckets).toHaveLength(2); expect(buckets[0]!.sessions).toHaveLength(1); expect(buckets[1]!.sessions).toHaveLength(1); }); it('combines multiple sessions sharing a videoId into one bucket with summed totals', () => { const a = makeSession({ sessionId: 1, videoId: 100, startedAtMs: 1_000_000, activeWatchedMs: 60_000, cardsMined: 2, }); const b = makeSession({ sessionId: 2, videoId: 100, startedAtMs: 2_000_000, activeWatchedMs: 120_000, cardsMined: 3, }); const buckets = groupSessionsByVideo([a, b]); expect(buckets).toHaveLength(1); const bucket = buckets[0]!; expect(bucket.sessions).toHaveLength(2); expect(bucket.totalActiveMs).toBe(180_000); expect(bucket.totalCardsMined).toBe(5); // Representative is the most-recent session. expect(bucket.representativeSession.sessionId).toBe(2); }); it('treats sessions with null/missing videoId as singletons keyed by sessionId', () => { const a = makeSession({ sessionId: 1, videoId: null as unknown as number }); const b = makeSession({ sessionId: 2, videoId: null as unknown as number }); const buckets = groupSessionsByVideo([a, b]); expect(buckets).toHaveLength(2); expect(buckets[0]!.key).toContain('1'); expect(buckets[1]!.key).toContain('2'); }); }); ``` - [ ] **Step 2: Run the failing tests** Run: `bun test stats/src/lib/session-grouping.test.ts` Expected: FAIL — module does not exist. - [ ] **Step 3: Implement the helper** Create `stats/src/lib/session-grouping.ts`: ```ts import type { SessionSummary } from '../types/stats'; export interface SessionBucket { key: string; videoId: number | null; sessions: SessionSummary[]; totalActiveMs: number; totalCardsMined: number; representativeSession: SessionSummary; } export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[] { const byVideo = new Map<string, SessionSummary[]>(); for (const session of sessions) { const hasVideoId = typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0; const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`; const existing = byVideo.get(key); if (existing) { existing.push(session); } else { byVideo.set(key, [session]); } } const buckets: SessionBucket[] = []; for (const [key, group] of byVideo) { const sorted = [...group].sort((a, b) => b.startedAtMs - a.startedAtMs); const representative = sorted[0]!; buckets.push({ key, videoId: typeof representative.videoId === 'number' && representative.videoId > 0 ? representative.videoId : null, sessions: sorted, totalActiveMs: sorted.reduce((sum, s) => sum + s.activeWatchedMs, 0), totalCardsMined: sorted.reduce((sum, s) => sum + s.cardsMined, 0), representativeSession: representative, }); } // Preserve insertion order — `byVideo` already keeps it (Map insertion order). return buckets; } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `bun test stats/src/lib/session-grouping.test.ts` Expected: PASS. - [ ] **Step 5: Typecheck** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 6: Commit** ```bash git add stats/src/lib/session-grouping.ts stats/src/lib/session-grouping.test.ts git commit -m "feat(stats): add groupSessionsByVideo helper for episode rollups" ``` --- ## Task 9: Sessions tab — episode rollup UI **Files:** - Modify: `stats/src/components/sessions/SessionsTab.tsx` - Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`) - Modify: `stats/src/lib/delete-confirm.test.ts` - Test: extend `stats/src/components/sessions/SessionsTab.test.tsx` if it exists; otherwise add a focused integration test on the new rollup behavior. - [ ] **Step 1: Add `confirmBucketDelete` with a failing test** In `stats/src/lib/delete-confirm.test.ts`, add: ```ts it('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { // mock globalThis.confirm to capture the prompt and return true const calls: string[] = []; const original = globalThis.confirm; globalThis.confirm = ((msg: string) => { calls.push(msg); return true; }) as typeof globalThis.confirm; try { expect(confirmBucketDelete('My Episode', 3)).toBe(true); expect(calls[0]).toContain('3'); expect(calls[0]).toContain('My Episode'); } finally { globalThis.confirm = original; } }); ``` Update the import line to also import `confirmBucketDelete`. - [ ] **Step 2: Run the failing test** Run: `bun test stats/src/lib/delete-confirm.test.ts -t 'confirmBucketDelete'` Expected: FAIL — function doesn't exist. - [ ] **Step 3: Add the helper** Append to `stats/src/lib/delete-confirm.ts`: ```ts export function confirmBucketDelete(title: string, count: number): boolean { return globalThis.confirm( `Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`, ); } ``` - [ ] **Step 4: Re-run the test** Run: `bun test stats/src/lib/delete-confirm.test.ts` Expected: PASS. - [ ] **Step 5: Add a failing test for the bucket UI** In a new or extended `stats/src/components/sessions/SessionsTab.test.tsx`, add a test that: - Stubs `useSessions` to return three sessions on the same day, two of which share a `videoId`. - Renders `<SessionsTab />`. - Asserts the page contains a bucket header for the shared-video pair (e.g. text matching `2 sessions`). - Asserts the singleton session's title appears once. - Clicks the bucket header and verifies the underlying two sessions become visible. - [ ] **Step 6: Run the failing test** Run: `bun test stats/src/components/sessions/SessionsTab.test.tsx -t 'rollup'` Expected: FAIL — current behavior renders three flat rows. - [ ] **Step 7: Restructure `SessionsTab.tsx` to use buckets** At the top of the component, import the helper: ```tsx import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping'; import { confirmBucketDelete } from '../../lib/delete-confirm'; ``` Add a second expanded-state Set keyed by bucket key: ```tsx const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(new Set()); const toggleBucket = (key: string) => { setExpandedBuckets((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; ``` Replace the inner day-group loop. Instead of mapping `daySessions.map(...)` directly, run them through `groupSessionsByVideo` and render each bucket. Buckets with one session keep the existing `SessionRow` rendering. Buckets with multiple sessions render a `<SessionBucketRow>` (a small inline component or a JSX block — keep it inline if the file isn't getting too long). Skeleton: ```tsx {Array.from(groups.entries()).map(([dayLabel, daySessions]) => { const buckets = groupSessionsByVideo(daySessions); return ( <div key={dayLabel}> <div className="flex items-center gap-3 mb-2"> <h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0"> {dayLabel} </h3> <div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" /> </div> <div className="space-y-2"> {buckets.map((bucket) => { if (bucket.sessions.length === 1) { const s = bucket.sessions[0]!; const detailsId = `session-details-${s.sessionId}`; return ( <div key={bucket.key}> <SessionRow session={s} isExpanded={expandedId === s.sessionId} detailsId={detailsId} onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} onDelete={() => void handleDeleteSession(s)} deleteDisabled={deletingSessionId === s.sessionId} onNavigateToMediaDetail={onNavigateToMediaDetail} /> {expandedId === s.sessionId && ( <div id={detailsId}> <SessionDetail session={s} /> </div> )} </div> ); } const isOpen = expandedBuckets.has(bucket.key); return ( <div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"> <button type="button" onClick={() => toggleBucket(bucket.key)} aria-expanded={isOpen} className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors" > <span aria-hidden="true" className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`} > {'\u25B6'} </span> <div className="min-w-0 flex-1"> <div className="text-sm text-ctp-text truncate"> {bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'} </div> <div className="text-xs text-ctp-overlay2"> {bucket.sessions.length} sessions ·{' '} {formatDuration(bucket.totalActiveMs)} ·{' '} {bucket.totalCardsMined} cards </div> </div> <button type="button" onClick={(e) => { e.stopPropagation(); void handleDeleteBucket(bucket); }} className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors" title="Delete all sessions in this group" > Delete </button> </button> {isOpen && ( <div className="pl-8 pr-2 pb-2 space-y-2"> {bucket.sessions.map((s) => { const detailsId = `session-details-${s.sessionId}`; return ( <div key={s.sessionId}> <SessionRow session={s} isExpanded={expandedId === s.sessionId} detailsId={detailsId} onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId) } onDelete={() => void handleDeleteSession(s)} deleteDisabled={deletingSessionId === s.sessionId} onNavigateToMediaDetail={onNavigateToMediaDetail} /> {expandedId === s.sessionId && ( <div id={detailsId}> <SessionDetail session={s} /> </div> )} </div> ); })} </div> )} </div> ); })} </div> </div> ); })} ``` **Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics: ```tsx <div className="flex items-center"> <button type="button" onClick={() => toggleBucket(bucket.key)} ...> ... </button> <button type="button" onClick={() => void handleDeleteBucket(bucket)} ...> Delete </button> </div> ``` Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones. Add `handleDeleteBucket`: ```tsx const handleDeleteBucket = async (bucket: SessionBucket) => { const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; if (!confirmBucketDelete(title, bucket.sessions.length)) return; setDeleteError(null); const ids = bucket.sessions.map((s) => s.sessionId); try { await apiClient.deleteSessions(ids); const idSet = new Set(ids); setVisibleSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); } }; ``` Add the `formatDuration` import at the top of the file if not present. - [ ] **Step 8: Run the bucket test** Run: `bun test stats/src/components/sessions/SessionsTab.test.tsx -t 'rollup'` Expected: PASS. - [ ] **Step 9: Run all sessions tests** Run: `bun test stats/src/components/sessions/` Expected: PASS. - [ ] **Step 10: Apply the same rollup to `MediaSessionList.tsx`** Read `stats/src/components/library/MediaSessionList.tsx` first. Inside a single video's detail view, all sessions share the same `videoId`, so `groupSessionsByVideo` would always produce one giant bucket. **That's wrong for this view.** Skip the bucket rendering here entirely — `MediaSessionList` still groups by day only. Document this in the commit message: "Rollup intentionally not applied in MediaSessionList because the view is already filtered to a single video." - [ ] **Step 11: Typecheck** Run: `bun run typecheck:stats` Expected: succeeds. - [ ] **Step 12: Commit** ```bash git add stats/src/components/sessions/SessionsTab.tsx \ stats/src/components/sessions/SessionsTab.test.tsx \ stats/src/lib/delete-confirm.ts stats/src/lib/delete-confirm.test.ts git commit -m "feat(stats): roll up same-episode sessions within a day" ``` --- ## Task 10: Chart clarity pass **Files:** - Modify: `stats/src/lib/chart-theme.ts` - Modify: `stats/src/components/trends/TrendChart.tsx` - Modify: `stats/src/components/trends/StackedTrendChart.tsx` - Modify: `stats/src/components/overview/WatchTimeChart.tsx` - Test: create `stats/src/lib/chart-theme.test.ts` if not present - [ ] **Step 1: Extend `chart-theme.ts`** Replace the file contents with: ```ts export const CHART_THEME = { tick: '#a5adcb', tooltipBg: '#363a4f', tooltipBorder: '#494d64', tooltipText: '#cad3f5', tooltipLabel: '#b8c0e0', barFill: '#8aadf4', grid: '#494d64', axisLine: '#494d64', } as const; export const CHART_DEFAULTS = { height: 160, tickFontSize: 11, margin: { top: 8, right: 8, bottom: 0, left: 0 }, grid: { strokeDasharray: '3 3', vertical: false }, } as const; export const TOOLTIP_CONTENT_STYLE = { background: CHART_THEME.tooltipBg, border: `1px solid ${CHART_THEME.tooltipBorder}`, borderRadius: 6, color: CHART_THEME.tooltipText, fontSize: 12, }; ``` - [ ] **Step 2: Add a snapshot/value test for the new constants** Create `stats/src/lib/chart-theme.test.ts`: ```ts import { describe, it, expect } from 'bun:test'; import { CHART_THEME, CHART_DEFAULTS, TOOLTIP_CONTENT_STYLE } from './chart-theme'; describe('chart-theme', () => { it('exposes a grid color', () => { expect(CHART_THEME.grid).toBe('#494d64'); }); it('uses 11px ticks for legibility', () => { expect(CHART_DEFAULTS.tickFontSize).toBe(11); }); it('builds a tooltip content style with border + background', () => { expect(TOOLTIP_CONTENT_STYLE.background).toBe(CHART_THEME.tooltipBg); expect(TOOLTIP_CONTENT_STYLE.border).toContain(CHART_THEME.tooltipBorder); }); }); ``` - [ ] **Step 3: Run the test** Run: `bun test stats/src/lib/chart-theme.test.ts` Expected: PASS. - [ ] **Step 4: Update `TrendChart.tsx` to use the shared theme + add gridlines** Replace `stats/src/components/trends/TrendChart.tsx` with: ```tsx import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, } from 'recharts'; import { CHART_THEME, CHART_DEFAULTS, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; interface TrendChartProps { title: string; data: Array<{ label: string; value: number }>; color: string; type: 'bar' | 'line'; formatter?: (value: number) => string; onBarClick?: (label: string) => void; } export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) { const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]); return ( <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3> <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}> {type === 'bar' ? ( <BarChart data={data} margin={CHART_DEFAULTS.margin}> <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> <XAxis dataKey="label" tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} /> <YAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} width={32} tickFormatter={formatter} /> <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> <Bar dataKey="value" fill={color} radius={[2, 2, 0, 0]} cursor={onBarClick ? 'pointer' : undefined} onClick={ onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined } /> </BarChart> ) : ( <LineChart data={data} margin={CHART_DEFAULTS.margin}> <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> <XAxis dataKey="label" tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} /> <YAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} width={32} tickFormatter={formatter} /> <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> <Line dataKey="value" stroke={color} strokeWidth={2} dot={false} /> </LineChart> )} </ResponsiveContainer> </div> ); } ``` - [ ] **Step 5: Update `StackedTrendChart.tsx` and `WatchTimeChart.tsx`** Open each file. For each chart container, apply the same recipe: 1. Import `CartesianGrid` from `recharts`. 2. Import `CHART_THEME`, `CHART_DEFAULTS`, `TOOLTIP_CONTENT_STYLE` from `'../../lib/chart-theme'`. 3. Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` as the first child of `<BarChart>`/`<LineChart>`. 4. Bump `<XAxis tick fontSize>` and `<YAxis tick fontSize>` to `CHART_DEFAULTS.tickFontSize`. 5. Add `axisLine={{ stroke: CHART_THEME.axisLine }}` to the Y axis. 6. Replace inline tooltip styles with `contentStyle={TOOLTIP_CONTENT_STYLE}`. 7. Bump `<ResponsiveContainer height>` from its current value to `CHART_DEFAULTS.height` (160) only if it's currently smaller than 160. Don't shrink anything. If either file already exposes formatter props for the Y axis, also pass `tickFormatter={formatter}` to `YAxis` so the unit suffix shows up. - [ ] **Step 6: Re-run the chart-theme test plus typecheck** Run: `bun test stats/src/lib/chart-theme.test.ts && bun run typecheck:stats` Expected: PASS + clean. - [ ] **Step 7: Sanity-check the overview tab still mounts** Run: `bun run build:stats` Expected: succeeds. - [ ] **Step 8: Commit** ```bash git add stats/src/lib/chart-theme.ts stats/src/lib/chart-theme.test.ts \ stats/src/components/trends/TrendChart.tsx \ stats/src/components/trends/StackedTrendChart.tsx \ stats/src/components/overview/WatchTimeChart.tsx git commit -m "feat(stats): unify chart theme and add gridlines for legibility" ``` --- ## Task 11: Changelog fragment **Files:** - Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md` - [ ] **Step 1: Read the existing changelog format** Run: `ls changes/ | head -5 && cat changes/$(ls changes/ | head -1)` Mirror that format exactly. - [ ] **Step 2: Write the fragment** Create `changes/2026-04-09-stats-dashboard-feedback-pass.md` with content like: ```markdown --- type: feature scope: stats --- Stats dashboard polish: - Library now collapses multi-episode series under a clickable header. - Sessions tab rolls up multiple sessions of the same episode within a day. - Trends gain a 365d range option. - Episodes can be deleted directly from the library detail view. - Top 50 vocabulary tightens word/reading spacing. - Cards deleted from Anki no longer appear in the episode detail card list. - Trend and watch-time charts gain horizontal gridlines, larger ticks, and a shared theme. ``` Adjust frontmatter keys/values to match whatever existing fragments use. - [ ] **Step 3: Validate** Run: `bun run changelog:lint && bun run changelog:pr-check` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add changes/2026-04-09-stats-dashboard-feedback-pass.md git commit -m "docs: add changelog fragment for stats dashboard feedback pass" ``` --- ## Final verification gate Run the project's standard handoff gate: - [ ] `bun run typecheck` - [ ] `bun run typecheck:stats` - [ ] `bun run test:fast` - [ ] `bun run test:env` - [ ] `bun run test:runtime:compat` - [ ] `bun run build` - [ ] `bun run test:smoke:dist` - [ ] `bun run format:check:src` - [ ] `bun run changelog:lint` - [ ] `bun run changelog:pr-check` If any of those fail, fix the underlying issue and create a new commit (do NOT amend earlier task commits — keep the per-task history clean). Then push the branch and open the PR. Suggested PR title: ``` Stats dashboard polish: collapsible library, session rollups, 365d trends, chart legibility, episode delete ``` Body should link to the spec at `docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md` and summarize each task. --- ## Risk callouts (for the implementing agent) - **Anki note-info loading-state guard (Task 5):** double-check the test case for the brief window before `ankiNotesInfo` resolves. Hiding everything during that window would be a regression. - **Nested button trap (Task 9):** the bucket header must place the toggle button and the delete button as siblings, not nested. Final code must use sibling buttons; the skeleton in the plan flags this. - **MediaSessionList (Task 9):** rollup is intentionally not applied there. Don't forget the commit message note. - **`useMediaLibrary` retry behavior (Task 6):** the existing hook auto-refetches when youtube metadata is missing. The new `refresh()` must not break that loop. The `[version]` dependency on the existing `useEffect` triggers a brand-new mount of the inner closure each call, which resets `retryCount` — that's the intended behavior. - **`bun test` resolves test files relative to repo root.** Always run from `/Users/sudacode/projects/japanese/SubMiner` (the worktree root), not from `stats/`. - **No file in this plan grows past ~250 lines after edits.** If a file does, that's a signal to extract — flag it on the way through.