Files
SubMiner/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md
sudacode 8874e2e1c6 docs: add stats dashboard feedback pass implementation plan
Task-by-task TDD plan for the seven items in the matching design
spec, organized as eleven commits in a single PR (365d backend,
server allow-list, frontend selector, vocabulary column,
Anki-deleted card filter, library episode delete, collapsible
library groups, session grouping helper, sessions tab rollup,
chart clarity pass, changelog fragment).
2026-04-09 00:28:41 -07:00

1610 lines
58 KiB
Markdown

# 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 <file>`, 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<Exclude<TrendRange, 'all'>, 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>): 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(<FrequencyRankTable words={[entry]} knownWords={new Set()} />);
// 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(<FrequencyRankTable words={[entry]} knownWords={new Set()} />);
// 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 `<th>`.
- [ ] **Step 4: Modify `FrequencyRankTable.tsx`**
Replace the `<th>Reading</th>` header column and the corresponding `<td>` in the body. The new shape:
Header (around line 113-119):
```tsx
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 font-medium w-20">Seen</th>
</tr>
</thead>
```
Body row (around line 122-141):
```tsx
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()}
</td>
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{(() => {
const reading = fullReading(w.headword, w.reading);
if (!reading || reading === w.headword) return null;
return (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
);
})()}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
```
- [ ] **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 <EpisodeDetail videoId={1} />.
// 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 `</div>` closing the cards-mined section, add:
```tsx
{hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
)}
```
Place that footer immediately before the closing `</div>` 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<Map<number, NoteInfo>>(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<number, NoteInfo>();
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(<MediaDetailView videoId={42} onBack={onBack} />);
// 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<MediaDetailData['detail']>;
initialKnownWordsSummary?: {
totalUniqueWords: number;
knownWordCount: number;
} | null;
onDeleteEpisode?: () => void;
}
export function MediaHeader({
detail,
initialKnownWordsSummary = null,
onDeleteEpisode,
}: MediaHeaderProps) {
```
Inside the right-hand `<div className="flex-1 min-w-0">`, immediately after the `<h2>` 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
<div className="flex items-start gap-2">
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">
{detail.canonicalTitle}
</h2>
{onDeleteEpisode && (
<button
type="button"
onClick={onDeleteEpisode}
className="text-xs text-ctp-red/80 hover:text-ctp-red px-2 py-1 rounded hover:bg-ctp-red/10 transition-colors shrink-0"
title="Delete this episode and all its sessions"
>
Delete Episode
</button>
)}
</div>
```
- [ ] **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: `<MediaHeader detail={detail} ... onDeleteEpisode={handleDeleteEpisode} />`.
- [ ] **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<MediaLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);
const refresh = useCallback(() => setVersion((v) => v + 1), []);
useEffect(() => {
let cancelled = false;
let retryCount = 0;
let retryTimer: ReturnType<typeof setTimeout> | 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 (
<MediaDetailView
videoId={selectedVideoId}
onBack={() => {
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(<title>)` 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.