diff --git a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md new file mode 100644 index 00000000..7fb37fe4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md @@ -0,0 +1,1347 @@ +# Library Summary Replaces Per-Day Trends — 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:** Replace the six noisy "Library — Per Day" stacked-area charts on the stats Trends tab with a single "Library — Summary" section containing a top-10 watch-time leaderboard and a sortable per-title table, both scoped to the existing date range. + +**Architecture:** Backend adds a `librarySummary: LibrarySummaryRow[]` field to the existing `/api/stats/trends/dashboard` response (aggregated from `imm_daily_rollups` + `imm_sessions` joined to `imm_videos`/`imm_anime`) and drops the now-unused `animePerDay` field. Frontend adds a new `LibrarySummarySection` React component (Recharts horizontal bar + sortable HTML table), replaces the per-day section in `TrendsTab.tsx`, and updates all test fixtures. + +**Tech Stack:** TypeScript, Bun test runner (backend), Node test runner (frontend), Recharts, React, Tailwind, SQLite (better-sqlite3 via `./sqlite` wrapper). + +**Spec:** `docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md` + +--- + +## File Structure + +**Backend (`src/core/services/immersion-tracker/`):** +- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`. +- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred). + +**Backend test fixtures:** +- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`. + +**Frontend (`stats/src/`):** +- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field. +- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`). +- `components/trends/LibrarySummarySection.tsx` — **new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props. +- `components/trends/TrendsTab.tsx` — delete the "Library — Per Day" block (lines 224-254 and the filtered data locals 137-146), add `LibrarySummarySection` import + usage, update `buildAnimeVisibilityOptions` call to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays. +- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision). + +**Changelog:** +- `changes/stats-library-summary.md` — **new** changelog fragment. + +--- + +## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field + +**Files:** +- Modify: `src/core/services/immersion-tracker/query-trends.ts` + +- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object** + +Edit `src/core/services/immersion-tracker/query-trends.ts`. After the existing `TrendPerAnimePoint` interface (around line 24), add: + +```ts +export interface LibrarySummaryRow { + title: string; + watchTimeMin: number; + videos: number; + sessions: number; + cards: number; + words: number; + lookups: number; + lookupsPerHundred: number | null; + firstWatched: number; + lastWatched: number; +} +``` + +In the same file, add a new field to `TrendsDashboardQueryResult` (around line 45-82), alongside `animePerDay`: + +```ts +librarySummary: LibrarySummaryRow[]; +``` + +In `getTrendsDashboard` (around line 622), add `librarySummary: []` to the returned object literal (inside the final `return { ... }`). Keep everything else as-is for now. + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: PASS (empty array satisfies the new field; no downstream consumer yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/core/services/immersion-tracker/query-trends.ts +git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field" +``` + +--- + +## Task 2: Backend — TDD the `buildLibrarySummary` helper + +**Files:** +- Modify: `src/core/services/immersion-tracker/query-trends.ts` +- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts` + +- [ ] **Step 1: Write a failing unit test for the happy path** + +Open `src/core/services/immersion-tracker/__tests__/query.test.ts` and add a new test at the end of the file (before the last closing brace, or after the last `test(...)` block — verify by reading the end of the file). Use the same imports/helpers the existing `getTrendsDashboard` tests use (`makeDbPath`, `ensureSchema`, `getOrCreateVideoRecord`, `getOrCreateAnimeRecord`, `linkVideoToAnimeRecord`, `startSessionRecord`, `createTrackerPreparedStatements`, `Database`, `cleanupDbPath`, `getTrendsDashboard`, `SOURCE_TYPE_LOCAL`). + +```ts +test('getTrendsDashboard builds librarySummary with per-title aggregates', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', { + canonicalTitle: 'Library Summary Test', + sourcePath: '/tmp/library-summary-test.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Summary Anime', + canonicalTitle: 'Summary Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'library-summary-test.mkv', + parsedTitle: 'Summary Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + + const dayOneStart = 1_700_000_000_000; + const dayTwoStart = dayOneStart + 86_400_000; + + const sessionOne = startSessionRecord(db, videoId, dayOneStart); + const sessionTwo = startSessionRecord(db, videoId, dayTwoStart); + + for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [ + [sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8], + [sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10], + ] as const) { + stmts.telemetryInsertStmt.run( + sessionId, + `${startedAtMs + 60_000}`, + activeMs, + activeMs, + 10, + tokens, + cards, + 0, + 0, + lookups, + 0, + 0, + 0, + 0, + `${startedAtMs + 60_000}`, + `${startedAtMs + 60_000}`, + ); + + db.prepare( + ` + UPDATE imm_sessions + SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?, + lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ? + WHERE session_id = ? + `, + ).run( + `${startedAtMs + activeMs}`, + activeMs, + activeMs, + 10, + tokens, + cards, + lookups, + sessionId, + ); + } + + for (const [day, active, tokens, cards] of [ + [Math.floor(dayOneStart / 86_400_000), 30, 120, 2], + [Math.floor(dayTwoStart / 86_400_000), 45, 140, 3], + ] as const) { + db.prepare( + ` + INSERT INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_tokens_seen, total_cards + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ).run(day, videoId, 1, active, 10, tokens, cards); + } + + const dashboard = getTrendsDashboard(db, 'all', 'day'); + + assert.equal(dashboard.librarySummary.length, 1); + const row = dashboard.librarySummary[0]!; + assert.equal(row.title, 'Summary Anime'); + assert.equal(row.watchTimeMin, 75); + assert.equal(row.videos, 1); + assert.equal(row.sessions, 2); + assert.equal(row.cards, 5); + assert.equal(row.words, 260); + assert.equal(row.lookups, 18); + assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1)); + assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000)); + assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000)); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t "librarySummary with per-title aggregates"` +Expected: FAIL — `dashboard.librarySummary.length` is `0`, not `1`. + +- [ ] **Step 3: Implement the `buildLibrarySummary` helper** + +Open `src/core/services/immersion-tracker/query-trends.ts`. Add this helper function near the other builders (e.g., after `buildCumulativePerAnime`, before `getVideoAnimeTitleMap`): + +```ts +function buildLibrarySummary( + rollups: ImmersionSessionRollupRow[], + sessions: TrendSessionMetricRow[], + titlesByVideoId: Map, +): LibrarySummaryRow[] { + type Accum = { + watchTimeMin: number; + videos: Set; + cards: number; + words: number; + firstWatched: number; + lastWatched: number; + sessions: number; + lookups: number; + }; + + const byTitle = new Map(); + + const ensure = (title: string): Accum => { + const existing = byTitle.get(title); + if (existing) return existing; + const created: Accum = { + watchTimeMin: 0, + videos: new Set(), + cards: 0, + words: 0, + firstWatched: Number.POSITIVE_INFINITY, + lastWatched: Number.NEGATIVE_INFINITY, + sessions: 0, + lookups: 0, + }; + byTitle.set(title, created); + return created; + }; + + for (const rollup of rollups) { + if (rollup.videoId === null) continue; + const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId); + const acc = ensure(title); + acc.watchTimeMin += rollup.totalActiveMin; + acc.cards += rollup.totalCards; + acc.words += rollup.totalTokensSeen; + acc.videos.add(rollup.videoId); + if (rollup.rollupDayOrMonth < acc.firstWatched) { + acc.firstWatched = rollup.rollupDayOrMonth; + } + if (rollup.rollupDayOrMonth > acc.lastWatched) { + acc.lastWatched = rollup.rollupDayOrMonth; + } + } + + for (const session of sessions) { + const title = resolveTrendAnimeTitle(session); + if (!byTitle.has(title)) continue; + const acc = byTitle.get(title)!; + acc.sessions += 1; + acc.lookups += session.yomitanLookupCount; + } + + const rows: LibrarySummaryRow[] = []; + for (const [title, acc] of byTitle) { + if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) { + continue; + } + rows.push({ + title, + watchTimeMin: Math.round(acc.watchTimeMin), + videos: acc.videos.size, + sessions: acc.sessions, + cards: acc.cards, + words: acc.words, + lookups: acc.lookups, + lookupsPerHundred: + acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null, + firstWatched: acc.firstWatched, + lastWatched: acc.lastWatched, + }); + } + + rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title)); + return rows; +} +``` + +- [ ] **Step 4: Wire `buildLibrarySummary` into `getTrendsDashboard`** + +Still in `query-trends.ts`, inside `getTrendsDashboard`, replace the stub `librarySummary: []` line with: + +```ts +librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), +``` + +Place it at the same spot in the return object (keep existing fields otherwise unchanged). + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t "librarySummary with per-title aggregates"` +Expected: PASS. + +- [ ] **Step 6: Run the full query test file to ensure no regressions** + +Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` +Expected: PASS for all tests. + +- [ ] **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): build per-title librarySummary from daily rollups and sessions" +``` + +--- + +## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests + +**Files:** +- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts` + +- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0** + +Append to `src/core/services/immersion-tracker/__tests__/query.test.ts`: + +```ts +test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lib-summary-null.mkv', { + canonicalTitle: 'Null Lookups Title', + sourcePath: '/tmp/lib-summary-null.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Null Lookups Anime', + canonicalTitle: 'Null Lookups Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'lib-summary-null.mkv', + parsedTitle: 'Null Lookups Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + + const startMs = 1_700_000_000_000; + const session = startSessionRecord(db, videoId, startMs); + stmts.telemetryInsertStmt.run( + session.sessionId, + `${startMs + 60_000}`, + 20 * 60_000, + 20 * 60_000, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + `${startMs + 60_000}`, + `${startMs + 60_000}`, + ); + db.prepare( + ` + UPDATE imm_sessions + SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?, + lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ? + WHERE session_id = ? + `, + ).run( + `${startMs + 20 * 60_000}`, + 20 * 60_000, + 20 * 60_000, + 5, + 0, + 0, + 0, + session.sessionId, + ); + + db.prepare( + ` + INSERT INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_tokens_seen, total_cards + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ).run(Math.floor(startMs / 86_400_000), videoId, 1, 20, 5, 0, 0); + + const dashboard = getTrendsDashboard(db, 'all', 'day'); + assert.equal(dashboard.librarySummary.length, 1); + assert.equal(dashboard.librarySummary[0]!.lookupsPerHundred, null); + assert.equal(dashboard.librarySummary[0]!.words, 0); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + +test('getTrendsDashboard librarySummary is empty when no rollups exist', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const dashboard = getTrendsDashboard(db, 'all', 'day'); + assert.deepEqual(dashboard.librarySummary, []); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); +``` + +- [ ] **Step 2: Run the new tests** + +Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t "librarySummary"` +Expected: PASS for all three librarySummary tests (the helper implemented in Task 2 already handles these cases). + +- [ ] **Step 3: Commit** + +```bash +git add src/core/services/immersion-tracker/__tests__/query.test.ts +git commit -m "test(stats): cover librarySummary null-lookups and empty-window cases" +``` + +--- + +## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers + +**Files:** +- Modify: `src/core/services/immersion-tracker/query-trends.ts` +- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts` +- Modify: `src/core/services/__tests__/stats-server.test.ts` + +- [ ] **Step 1: Remove `animePerDay` from `TrendsDashboardQueryResult`** + +In `src/core/services/immersion-tracker/query-trends.ts`, delete the `animePerDay` block from the interface (lines ~64-71): + +```ts +// Delete this block: +animePerDay: { + episodes: TrendPerAnimePoint[]; + watchTime: TrendPerAnimePoint[]; + cards: TrendPerAnimePoint[]; + words: TrendPerAnimePoint[]; + lookups: TrendPerAnimePoint[]; + lookupsPerHundred: TrendPerAnimePoint[]; +}; +``` + +- [ ] **Step 2: Scope the intermediate `animePerDay` to a local variable and drop it from the return** + +In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement: + +```ts + const animePerDay = { + episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId), + watchTime: buildPerAnimeFromDailyRollups( + dailyRollups, + titlesByVideoId, + (rollup) => rollup.totalActiveMin, + ), + cards: buildPerAnimeFromDailyRollups( + dailyRollups, + titlesByVideoId, + (rollup) => rollup.totalCards, + ), + words: buildPerAnimeFromDailyRollups( + dailyRollups, + titlesByVideoId, + (rollup) => rollup.totalTokensSeen, + ), + }; + + return { + activity, + progress: { + watchTime: accumulatePoints(activity.watchTime), + sessions: accumulatePoints(activity.sessions), + words: accumulatePoints(activity.words), + newWords: accumulatePoints( + useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs), + ), + cards: accumulatePoints(activity.cards), + episodes: accumulatePoints( + useMonthlyBuckets + ? buildEpisodesPerMonthFromRollups(monthlyRollups) + : buildEpisodesPerDayFromDailyRollups(dailyRollups), + ), + lookups: accumulatePoints( + useMonthlyBuckets + ? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount) + : buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount), + ), + }, + ratios: { + lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), + }, + librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), + animeCumulative: { + watchTime: buildCumulativePerAnime(animePerDay.watchTime), + episodes: buildCumulativePerAnime(animePerDay.episodes), + cards: buildCumulativePerAnime(animePerDay.cards), + words: buildCumulativePerAnime(animePerDay.words), + }, + patterns: { + watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), + watchTimeByHour: buildWatchTimeByHour(sessions), + }, + }; +``` + +- [ ] **Step 3: Delete now-unused helpers** + +In the same file, delete the functions `buildPerAnimeFromSessions` (around lines 304-325) and `buildLookupsPerHundredPerAnime` (around lines 327-357). Nothing else references them after Step 2. + +- [ ] **Step 4: Update the existing `getTrendsDashboard` test assertion that references `animePerDay`** + +In `src/core/services/immersion-tracker/__tests__/query.test.ts`, find the test `getTrendsDashboard returns chart-ready aggregated series`. Replace the line: + +```ts +assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime'); +``` + +with: + +```ts +assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime'); +``` + +- [ ] **Step 5: Update the stats-server test fixture** + +In `src/core/services/__tests__/stats-server.test.ts`, find `TRENDS_DASHBOARD` (around line 150). Remove the entire `animePerDay: { ... }` block (lines ~169-176). Add a `librarySummary` field inside the fixture (anywhere appropriate — before `animeCumulative` is fine): + +```ts + librarySummary: [ + { + title: 'Little Witch Academia', + watchTimeMin: 25, + videos: 1, + sessions: 1, + cards: 5, + words: 300, + lookups: 15, + lookupsPerHundred: 5, + firstWatched: 20_000, + lastWatched: 20_000, + }, + ], +``` + +Then find the assertion around line 601: + +```ts +assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime); +``` + +Replace it with: + +```ts +assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); +``` + +- [ ] **Step 6: Run typecheck + backend tests** + +Run: `bun run typecheck` +Expected: PASS. + +Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` +Expected: PASS. + +Run: `bun test src/core/services/__tests__/stats-server.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/core/services/immersion-tracker/query-trends.ts \ + src/core/services/immersion-tracker/__tests__/query.test.ts \ + src/core/services/__tests__/stats-server.test.ts +git commit -m "refactor(stats): drop animePerDay from trends response in favor of librarySummary" +``` + +--- + +## Task 5: Frontend — Update types and api-client test fixtures + +**Files:** +- Modify: `stats/src/types/stats.ts` +- Modify: `stats/src/lib/api-client.test.ts` + +- [ ] **Step 1: Add `LibrarySummaryRow` and update `TrendsDashboardData` in `stats/src/types/stats.ts`** + +Add above `TrendsDashboardData` (around line 291): + +```ts +export interface LibrarySummaryRow { + title: string; + watchTimeMin: number; + videos: number; + sessions: number; + cards: number; + words: number; + lookups: number; + lookupsPerHundred: number | null; + firstWatched: number; + lastWatched: number; +} +``` + +Inside `TrendsDashboardData`, delete the `animePerDay` block (lines ~310-317) and add `librarySummary: LibrarySummaryRow[];` (place it before `animeCumulative`): + +```ts +export interface TrendsDashboardData { + activity: { + watchTime: TrendChartPoint[]; + cards: TrendChartPoint[]; + words: TrendChartPoint[]; + sessions: TrendChartPoint[]; + }; + progress: { + watchTime: TrendChartPoint[]; + sessions: TrendChartPoint[]; + words: TrendChartPoint[]; + newWords: TrendChartPoint[]; + cards: TrendChartPoint[]; + episodes: TrendChartPoint[]; + lookups: TrendChartPoint[]; + }; + ratios: { + lookupsPerHundred: TrendChartPoint[]; + }; + librarySummary: LibrarySummaryRow[]; + animeCumulative: { + watchTime: TrendPerAnimePoint[]; + episodes: TrendPerAnimePoint[]; + cards: TrendPerAnimePoint[]; + words: TrendPerAnimePoint[]; + }; + patterns: { + watchTimeByDayOfWeek: TrendChartPoint[]; + watchTimeByHour: TrendChartPoint[]; + }; +} +``` + +- [ ] **Step 2: Update the two inline fixtures in `stats/src/lib/api-client.test.ts`** + +Find both inline `JSON.stringify({ ... })` fetch-mock bodies (around lines 75-107 and 123-150). In **both** blocks, delete the `animePerDay: { ... }` object and replace with: + +```ts +librarySummary: [], +``` + +(Insert before `animeCumulative`.) + +- [ ] **Step 3: Run frontend typecheck and tests** + +Run: `cd stats && bun run typecheck` +Expected: FAIL — `TrendsTab.tsx` still references `data.animePerDay`. That's expected; we fix it in Task 8. Continue. + +Run: `cd stats && bun test src/lib/api-client.test.ts` +Expected: PASS (the test only asserts URL construction, not response shape). + +- [ ] **Step 4: Commit** + +```bash +git add stats/src/types/stats.ts stats/src/lib/api-client.test.ts +git commit -m "refactor(stats): replace animePerDay type with librarySummary" +``` + +--- + +## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state + +**Files:** +- Create: `stats/src/components/trends/LibrarySummarySection.tsx` + +- [ ] **Step 1: Create the file with the empty state and props plumbing** + +Create `stats/src/components/trends/LibrarySummarySection.tsx`: + +```tsx +import type { LibrarySummaryRow } from '../../types/stats'; + +interface LibrarySummarySectionProps { + rows: LibrarySummaryRow[]; + hiddenTitles: ReadonlySet; +} + +export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) { + const visibleRows = rows.filter((row) => !hiddenTitles.has(row.title)); + + if (visibleRows.length === 0) { + return ( +
+
No library activity in the selected window.
+
+ ); + } + + return ( + <> + {/* Leaderboard + table cards added in Tasks 7 and 8 */} +
+
+ Library summary: {visibleRows.length} titles +
+
+ + ); +} +``` + +- [ ] **Step 2: Run typecheck (it will still fail in `TrendsTab.tsx`, but the new file should typecheck cleanly)** + +Run: `cd stats && bun run typecheck 2>&1 | grep -E 'LibrarySummarySection\.tsx'` +Expected: no output (new file has no type errors). `TrendsTab.tsx` still errors — ignore until Task 8. + +- [ ] **Step 3: Commit** + +```bash +git add stats/src/components/trends/LibrarySummarySection.tsx +git commit -m "feat(stats): scaffold LibrarySummarySection with empty state" +``` + +--- + +## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection` + +**Files:** +- Modify: `stats/src/components/trends/LibrarySummarySection.tsx` + +- [ ] **Step 1: Replace the skeleton body with the leaderboard chart** + +Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with: + +```tsx +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { LibrarySummaryRow } from '../../types/stats'; +import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; + +interface LibrarySummarySectionProps { + rows: LibrarySummaryRow[]; + hiddenTitles: ReadonlySet; +} + +const LEADERBOARD_LIMIT = 10; +const LEADERBOARD_HEIGHT = 260; +const LEADERBOARD_BAR_COLOR = '#8aadf4'; + +function truncateTitle(title: string, maxChars: number): string { + if (title.length <= maxChars) return title; + return `${title.slice(0, maxChars - 1)}…`; +} + +export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) { + const visibleRows = rows.filter((row) => !hiddenTitles.has(row.title)); + + if (visibleRows.length === 0) { + return ( +
+
No library activity in the selected window.
+
+ ); + } + + const leaderboard = [...visibleRows] + .sort((a, b) => b.watchTimeMin - a.watchTimeMin) + .slice(0, LEADERBOARD_LIMIT) + .map((row) => ({ + title: row.title, + displayTitle: truncateTitle(row.title, 24), + watchTimeMin: row.watchTimeMin, + })); + + return ( + <> +
+

+ Top Titles by Watch Time (min) +

+ + + + + + [`${value} min`, 'Watch Time']} + labelFormatter={(_label, payload) => { + const datum = payload?.[0]?.payload as { title?: string } | undefined; + return datum?.title ?? ''; + }} + /> + + + +
+ {/* Table card added in Task 8 */} + + ); +} +``` + +- [ ] **Step 2: Typecheck (component in isolation)** + +Run: `cd stats && bun run typecheck 2>&1 | grep -E 'LibrarySummarySection\.tsx'` +Expected: no output — new component typechecks. `TrendsTab.tsx` errors remain (fixed in Task 9). + +- [ ] **Step 3: Commit** + +```bash +git add stats/src/components/trends/LibrarySummarySection.tsx +git commit -m "feat(stats): add top-titles leaderboard chart to LibrarySummarySection" +``` + +--- + +## Task 8: Frontend — Add the sortable table to `LibrarySummarySection` + +**Files:** +- Modify: `stats/src/components/trends/LibrarySummarySection.tsx` + +- [ ] **Step 1: Add sort state, column definitions, and the table markup** + +Replace the entire file with the version below. The change vs. Task 7: imports `useState`, `useMemo`, and `formatDuration` + `epochDayToDate`; adds `SortColumn`, `SortDirection`, `COLUMNS`; adds a `` card after the leaderboard card. + +```tsx +import { useMemo, useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { LibrarySummaryRow } from '../../types/stats'; +import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; +import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters'; + +interface LibrarySummarySectionProps { + rows: LibrarySummaryRow[]; + hiddenTitles: ReadonlySet; +} + +const LEADERBOARD_LIMIT = 10; +const LEADERBOARD_HEIGHT = 260; +const LEADERBOARD_BAR_COLOR = '#8aadf4'; +const TABLE_MAX_HEIGHT = 480; + +type SortColumn = + | 'title' + | 'watchTimeMin' + | 'videos' + | 'sessions' + | 'cards' + | 'words' + | 'lookups' + | 'lookupsPerHundred' + | 'firstWatched'; + +type SortDirection = 'asc' | 'desc'; + +interface ColumnDef { + id: SortColumn; + label: string; + align: 'left' | 'right'; +} + +const COLUMNS: ColumnDef[] = [ + { id: 'title', label: 'Title', align: 'left' }, + { id: 'watchTimeMin', label: 'Watch Time', align: 'right' }, + { id: 'videos', label: 'Videos', align: 'right' }, + { id: 'sessions', label: 'Sessions', align: 'right' }, + { id: 'cards', label: 'Cards', align: 'right' }, + { id: 'words', label: 'Words', align: 'right' }, + { id: 'lookups', label: 'Lookups', align: 'right' }, + { id: 'lookupsPerHundred', label: 'Lookups/100w', align: 'right' }, + { id: 'firstWatched', label: 'Date Range', align: 'right' }, +]; + +function truncateTitle(title: string, maxChars: number): string { + if (title.length <= maxChars) return title; + return `${title.slice(0, maxChars - 1)}…`; +} + +function formatDateRange(firstEpochDay: number, lastEpochDay: number): string { + const fmt = (epochDay: number) => + epochDayToDate(epochDay).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + if (firstEpochDay === lastEpochDay) return fmt(firstEpochDay); + return `${fmt(firstEpochDay)} → ${fmt(lastEpochDay)}`; +} + +function formatWatchTime(min: number): string { + return formatDuration(min * 60_000); +} + +function compareRows( + a: LibrarySummaryRow, + b: LibrarySummaryRow, + column: SortColumn, + direction: SortDirection, +): number { + const sign = direction === 'asc' ? 1 : -1; + + if (column === 'title') { + return a.title.localeCompare(b.title) * sign; + } + + if (column === 'firstWatched') { + return (a.firstWatched - b.firstWatched) * sign; + } + + if (column === 'lookupsPerHundred') { + // Null sorts as lowest in both directions (treated as "no data"). + const aVal = a.lookupsPerHundred; + const bVal = b.lookupsPerHundred; + if (aVal === null && bVal === null) return 0; + if (aVal === null) return 1; + if (bVal === null) return -1; + return (aVal - bVal) * sign; + } + + const aVal = a[column] as number; + const bVal = b[column] as number; + return (aVal - bVal) * sign; +} + +export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) { + const [sortColumn, setSortColumn] = useState('watchTimeMin'); + const [sortDirection, setSortDirection] = useState('desc'); + + const visibleRows = useMemo( + () => rows.filter((row) => !hiddenTitles.has(row.title)), + [rows, hiddenTitles], + ); + + const sortedRows = useMemo( + () => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)), + [visibleRows, sortColumn, sortDirection], + ); + + const leaderboard = useMemo( + () => + [...visibleRows] + .sort((a, b) => b.watchTimeMin - a.watchTimeMin) + .slice(0, LEADERBOARD_LIMIT) + .map((row) => ({ + title: row.title, + displayTitle: truncateTitle(row.title, 24), + watchTimeMin: row.watchTimeMin, + })), + [visibleRows], + ); + + if (visibleRows.length === 0) { + return ( +
+
+ No library activity in the selected window. +
+
+ ); + } + + const handleHeaderClick = (column: SortColumn) => { + if (column === sortColumn) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortColumn(column); + setSortDirection(column === 'title' ? 'asc' : 'desc'); + } + }; + + return ( + <> +
+

+ Top Titles by Watch Time (min) +

+ + + + + + [`${value} min`, 'Watch Time']} + labelFormatter={(_label, payload) => { + const datum = payload?.[0]?.payload as { title?: string } | undefined; + return datum?.title ?? ''; + }} + /> + + + +
+
+

Per-Title Summary

+
+
+ + + {COLUMNS.map((column) => { + const isActive = column.id === sortColumn; + const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : ''; + return ( + + ); + })} + + + + {sortedRows.map((row) => ( + + + + + + + + + + + + ))} + +
handleHeaderClick(column.id)} + > + {column.label} + {indicator} +
+ {row.title} + + {formatWatchTime(row.watchTimeMin)} + + {formatNumber(row.videos)} + + {formatNumber(row.sessions)} + + {formatNumber(row.cards)} + + {formatNumber(row.words)} + + {formatNumber(row.lookups)} + + {row.lookupsPerHundred === null + ? '—' + : row.lookupsPerHundred.toFixed(1)} + + {formatDateRange(row.firstWatched, row.lastWatched)} +
+ + + + ); +} +``` + +- [ ] **Step 2: Typecheck the new component** + +Run: `cd stats && bun run typecheck 2>&1 | grep -E 'LibrarySummarySection\.tsx'` +Expected: no output. `TrendsTab.tsx` errors still remain — next task fixes them. + +- [ ] **Step 3: Commit** + +```bash +git add stats/src/components/trends/LibrarySummarySection.tsx +git commit -m "feat(stats): add sortable per-title table to LibrarySummarySection" +``` + +--- + +## Task 9: Frontend — Wire `LibrarySummarySection` into `TrendsTab` and remove the per-day block + +**Files:** +- Modify: `stats/src/components/trends/TrendsTab.tsx` + +- [ ] **Step 1: Delete the per-day filtered locals and imports** + +In `stats/src/components/trends/TrendsTab.tsx`: + +Delete these locals (currently lines ~129-146): + +```ts +const filteredEpisodesPerAnime = filterHiddenAnimeData( + data.animePerDay.episodes, + activeHiddenAnime, +); +const filteredWatchTimePerAnime = filterHiddenAnimeData( + data.animePerDay.watchTime, + activeHiddenAnime, +); +const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime); +const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime); +const filteredLookupsPerAnime = filterHiddenAnimeData( + data.animePerDay.lookups, + activeHiddenAnime, +); +const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData( + data.animePerDay.lookupsPerHundred, + activeHiddenAnime, +); +``` + +- [ ] **Step 2: Update `buildAnimeVisibilityOptions` to use `librarySummary` titles** + +Replace the existing `const animeTitles = buildAnimeVisibilityOptions([...])` block (currently lines 116-126) with: + +```ts +const librarySummaryAsPoints = data.librarySummary.map((row) => ({ + epochDay: 0, + animeTitle: row.title, + value: row.watchTimeMin, +})); + +const animeTitles = buildAnimeVisibilityOptions([ + librarySummaryAsPoints, + data.animeCumulative.episodes, + data.animeCumulative.cards, + data.animeCumulative.words, + data.animeCumulative.watchTime, +]); +``` + +This reuses the existing `PerAnimeDataPoint`-shaped helper without modifying it — the `epochDay: 0` is a placeholder the helper never inspects. + +- [ ] **Step 3: Import `LibrarySummarySection` at the top of the file** + +Add to the imports at the top (near the other `./` imports on line 5): + +```ts +import { LibrarySummarySection } from './LibrarySummarySection'; +``` + +- [ ] **Step 4: Replace the "Library — Per Day" JSX block** + +Find lines 224-254 (the block starting with `Library — Per Day`). Replace the entire block through the final `/>` of `Lookups/100w per Title` with: + +```tsx +Library — Summary + setHiddenAnime(new Set())} + onHideAll={() => setHiddenAnime(new Set(animeTitles))} + onToggleAnime={(title) => + setHiddenAnime((current) => { + const next = new Set(current); + if (next.has(title)) { + next.delete(title); + } else { + next.add(title); + } + return next; + }) + } +/> + +``` + +(The `AnimeVisibilityFilter` moves from the per-day section into the summary section — same component, same props pattern.) + +- [ ] **Step 5: Verify `StackedTrendChart` and `filterHiddenAnimeData` are still imported** + +Those imports are still needed by the "Library — Cumulative" section (lines 256-264 — make sure you did NOT delete them). If the linter reports them as unused, they aren't. Do not touch them. + +- [ ] **Step 6: Run frontend typecheck** + +Run: `cd stats && bun run typecheck` +Expected: PASS (no more `animePerDay` references). + +- [ ] **Step 7: Run the full fast test suite** + +Run: `bun run test:fast` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add stats/src/components/trends/TrendsTab.tsx +git commit -m "feat(stats): replace per-day trends section with library summary" +``` + +--- + +## Task 10: Add changelog fragment and run the full handoff gate + +**Files:** +- Create: `changes/stats-library-summary.md` + +- [ ] **Step 1: Check the existing changelog fragment format** + +Run: `ls changes/ && head -20 changes/*.md | head -60` +Inspect a recent fragment to match the exact format (frontmatter, section headings). Base the new fragment on whatever convention you see — do not guess. + +- [ ] **Step 2: Write the fragment** + +Create `changes/stats-library-summary.md` using the format you just observed. The body should say something like: + +> Replaced the noisy "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector. + +If you are uncertain about the format, copy the most recent fragment's structure exactly and replace only the body text and category. + +- [ ] **Step 3: Run the default handoff gate** + +Run the commands in sequence (stop and fix if any fails): + +```bash +bun run typecheck +bun run test:fast +bun run test:env +bun run changelog:lint +``` + +Expected: all PASS. + +- [ ] **Step 4: Run the full build + smoke test** + +```bash +bun run build +bun run test:smoke:dist +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the fragment** + +```bash +git add changes/stats-library-summary.md +git commit -m "docs(changelog): summarize library summary replacing per-day trends" +``` + +--- + +## Verification Checklist + +After all tasks complete, manually verify: + +- [ ] The "Library — Per Day" section is gone from the Trends tab. +- [ ] A new "Library — Summary" section appears with a top-10 watch-time bar chart above a per-title table. +- [ ] Clicking table column headers sorts the table; clicking twice reverses direction. +- [ ] The shared Anime Visibility filter still hides titles from both the leaderboard, the table, and the Cumulative section below. +- [ ] Changing the date range selector (7d/30d/90d/365d/all) updates the summary. +- [ ] Titles with `words === 0` show `—` in the Lookups/100w column. +- [ ] Empty window shows "No library activity in the selected window." +- [ ] The "Library — Cumulative" section below is unchanged. +- [ ] `git log --oneline` shows small, focused commits per task. diff --git a/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md b/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md index b69c6a49..22858bde 100644 --- a/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md +++ b/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md @@ -69,14 +69,11 @@ type LibrarySummaryRow = { ### Removed from API response -- `animePerDay.lookups` -- `animePerDay.lookupsPerHundred` -- `animePerDay.episodes` (if no other consumer — verify during implementation) -- `animePerDay.watchTime` (if no other consumer — verify during implementation) -- `animePerDay.cards` (if no other consumer — verify during implementation) -- `animePerDay.words` (if no other consumer — verify during implementation) +Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`). -If all four "if no other consumer" fields can be dropped, remove the `animePerDay` object from the response entirely along with the helpers that produce it. Check every use site in `stats/` before deleting. +Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted. + +Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it. ## Frontend @@ -101,11 +98,13 @@ Both cards use the existing chart/card wrapper styling. ### Leaderboard chart -- ECharts horizontal bar chart (matches the rest of the page). +- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts). - Top 10 titles by watch time. If fewer titles have activity, render what's there. -- Y-axis: title, truncated with ellipsis at container width; full title on hover via ECharts tooltip. -- X-axis: minutes. +- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip. +- X-axis: minutes (number). +- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`. - Single series color: `#8aadf4` (matching the existing Watch Time color). +- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard. - Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed. ### Table