From 928a0d6b61429bdd9e2d8066d322ae63d46e4c39 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 9 Apr 2026 21:51:02 -0700 Subject: [PATCH] docs(plans): add implementation plan for library summary replaces per-day Also corrects the spec's chart library reference (Recharts, not ECharts) and clarifies that the backend keeps the internal animePerDay computation as an intermediate for animeCumulative, only dropping it from the API response. --- ...-04-09-library-summary-replaces-per-day.md | 1347 +++++++++++++++++ ...library-summary-replaces-per-day-design.md | 19 +- 2 files changed, 1356 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md 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