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

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

+ {detail.canonicalTitle} +

+ {onDeleteEpisode && ( + + )} +
+ ``` + +- [ ] **Step 4: Wire `onDeleteEpisode` in `MediaDetailView.tsx`** + + Add a handler near the existing `handleDeleteSession`: + + ```tsx + const handleDeleteEpisode = async () => { + const title = data.detail.canonicalTitle; + if (!confirmEpisodeDelete(title)) return; + setDeleteError(null); + try { + await apiClient.deleteVideo(videoId); + onBack(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.'); + } + }; + ``` + + Add `confirmEpisodeDelete` to the existing `delete-confirm` import line. + + Pass the handler down: ``. + +- [ ] **Step 5: Run the test to verify it passes** + + Run: `bun test stats/src/components/library/MediaDetailView.test.tsx -t 'delete'` + Expected: PASS. + +- [ ] **Step 6: Add `refresh` to `useMediaLibrary`** + + In `stats/src/hooks/useMediaLibrary.ts`, hoist the `load` function out of `useEffect` using `useCallback`, and return a `refresh` function: + + ```tsx + import { useState, useEffect, useCallback } from 'react'; + + export function useMediaLibrary() { + const [media, setMedia] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [version, setVersion] = useState(0); + + const refresh = useCallback(() => setVersion((v) => v + 1), []); + + useEffect(() => { + let cancelled = false; + let retryCount = 0; + let retryTimer: ReturnType | null = null; + + const load = (isInitial = false) => { + if (isInitial) { + setLoading(true); + setError(null); + } + getStatsClient() + .getMediaLibrary() + .then((rows) => { + if (cancelled) return; + setMedia(rows); + if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) { + retryCount += 1; + retryTimer = setTimeout(() => { + retryTimer = null; + load(false); + }, MEDIA_LIBRARY_REFRESH_DELAY_MS); + } + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (cancelled || !isInitial) return; + setLoading(false); + }); + }; + + load(true); + return () => { + cancelled = true; + if (retryTimer) { + clearTimeout(retryTimer); + } + }; + }, [version]); + + return { media, loading, error, refresh }; + } + ``` + +- [ ] **Step 7: Add a focused test for `refresh`** + + In `stats/src/hooks/useMediaLibrary.test.ts`, add a test that: + - Mounts the hook with `renderHook` from `@testing-library/react`. + - Asserts `getMediaLibrary` was called once. + - Calls `result.current.refresh()` inside `act`. + - Asserts `getMediaLibrary` was called twice. + + If the file doesn't have `renderHook` patterns, mirror whichever helper the existing tests use. Look at the existing test file first. + +- [ ] **Step 8: Wire `refresh` from `LibraryTab.tsx`** + + In `stats/src/components/library/LibraryTab.tsx`: + + ```tsx + const { media, loading, error, refresh } = useMediaLibrary(); + ``` + + And update the early-return that mounts the detail view: + + ```tsx + if (selectedVideoId !== null) { + return ( + { + setSelectedVideoId(null); + refresh(); + }} + /> + ); + } + ``` + +- [ ] **Step 9: Run the new tests** + + Run: `bun test stats/src/hooks/useMediaLibrary.test.ts && bun test stats/src/components/library/MediaDetailView.test.tsx` + Expected: PASS. + +- [ ] **Step 10: Typecheck** + + Run: `bun run typecheck:stats` + Expected: succeeds. + +- [ ] **Step 11: Commit** + + ```bash + git add stats/src/components/library/MediaHeader.tsx \ + stats/src/components/library/MediaDetailView.tsx \ + stats/src/components/library/MediaDetailView.test.tsx \ + stats/src/components/library/LibraryTab.tsx \ + stats/src/hooks/useMediaLibrary.ts \ + stats/src/hooks/useMediaLibrary.test.ts + git commit -m "feat(stats): delete episode from library detail view" + ``` + +--- + +## Task 7: Library — collapsible series groups + +**Files:** +- Modify: `stats/src/components/library/LibraryTab.tsx` +- Test: create `stats/src/components/library/LibraryTab.test.tsx` + +- [ ] **Step 1: Write the failing tests** + + Create `stats/src/components/library/LibraryTab.test.tsx`. Mirror the import/mocking pattern from `MediaDetailView.test.tsx`. Stub `useMediaLibrary` to return: + - One group with three videos (multi-video series). + - One group with one video (singleton). + + Add three tests: + + ```tsx + it('renders the multi-video group collapsed by default', async () => { + // Render LibraryTab with stubbed hook. + // Assert: the group header is visible. + // Assert: the three video MediaCards are NOT in the DOM (collapsed). + }); + + it('renders the single-video group expanded by default', async () => { + // Assert: the singleton's MediaCard IS in the DOM. + }); + + it('toggles the collapsed group when its header is clicked', async () => { + // Click the multi-video group header. + // Assert: the three MediaCards now appear. + // Click again. + // Assert: they disappear. + }); + ``` + + How to identify cards: each `MediaCard` should expose its title via the cover image alt text or a title element. Use `screen.queryAllByText()` to count them. + +- [ ] **Step 2: Run the failing tests** + + Run: `bun test stats/src/components/library/LibraryTab.test.tsx` + Expected: FAIL — current `LibraryTab` always shows all cards. + +- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`** + + Modify imports: + ```tsx + import { useState, useMemo, useCallback } from 'react'; + ``` + + Inside the component, after the existing `useState` calls: + ```tsx + const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set()); + + // When grouped data changes, default-collapse groups with >1 video. + // We do this declaratively in a useMemo to keep state derived. + const effectiveCollapsed = useMemo(() => { + const next = new Set(collapsedGroups); + for (const group of grouped) { + // Only auto-collapse on first encounter; if user has interacted, leave alone. + // We do this by tracking which keys we've seen via a ref. Simpler approach: + // initialize on mount via useEffect below. + } + return next; + }, [collapsedGroups, grouped]); + ``` + + Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**: + ```tsx + const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set()); + const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false); + + useEffect(() => { + if (hasInitializedCollapsed || grouped.length === 0) return; + const initial = new Set<string>(); + for (const group of grouped) { + if (group.items.length > 1) initial.add(group.key); + } + setCollapsedGroups(initial); + setHasInitializedCollapsed(true); + }, [grouped, hasInitializedCollapsed]); + + const toggleGroup = useCallback((key: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + ``` + + Don't forget to add `useEffect` to the import line. + +- [ ] **Step 4: Update the group rendering** + + Replace the section block (around line 64-115) so the header is a `<button>`: + + ```tsx + {grouped.map((group) => { + const isCollapsed = collapsedGroups.has(group.key); + const isSingleVideo = group.items.length === 1; + return ( + <section + key={group.key} + className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden" + > + <button + type="button" + onClick={() => !isSingleVideo && toggleGroup(group.key)} + aria-expanded={!isCollapsed} + aria-controls={`group-body-${group.key}`} + disabled={isSingleVideo} + className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${ + isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer' + }`} + > + {!isSingleVideo && ( + <span + aria-hidden="true" + className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${ + isCollapsed ? '' : 'rotate-90' + }`} + > + {'\u25B6'} + </span> + )} + <CoverImage + videoId={group.items[0]!.videoId} + title={group.title} + src={group.imageUrl} + className="w-16 h-16 rounded-2xl shrink-0" + /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <h3 className="text-base font-semibold text-ctp-text truncate"> + {group.title} + </h3> + </div> + {group.subtitle ? ( + <div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div> + ) : null} + <div className="text-xs text-ctp-overlay2 mt-2"> + {group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '} + {formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards + </div> + </div> + </button> + {!isCollapsed && ( + <div id={`group-body-${group.key}`} className="p-4"> + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> + {group.items.map((item) => ( + <MediaCard + key={item.videoId} + item={item} + onClick={() => setSelectedVideoId(item.videoId)} + /> + ))} + </div> + </div> + )} + </section> + ); + })} + ``` + + **Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption. + +- [ ] **Step 5: Run the tests to verify they pass** + + Run: `bun test stats/src/components/library/LibraryTab.test.tsx` + Expected: PASS. + +- [ ] **Step 6: Typecheck** + + Run: `bun run typecheck:stats` + Expected: succeeds. + +- [ ] **Step 7: Commit** + + ```bash + git add stats/src/components/library/LibraryTab.tsx \ + stats/src/components/library/LibraryTab.test.tsx + git commit -m "feat(stats): collapsible series groups in library tab" + ``` + +--- + +## Task 8: Session grouping helper + +**Files:** +- Create: `stats/src/lib/session-grouping.ts` +- Create: `stats/src/lib/session-grouping.test.ts` + +- [ ] **Step 1: Write the failing tests** + + Create `stats/src/lib/session-grouping.test.ts`: + + ```ts + import { describe, it, expect } from 'bun:test'; + import { groupSessionsByVideo } from './session-grouping'; + import type { SessionSummary } from '../types/stats'; + + function makeSession(over: Partial<SessionSummary>): SessionSummary { + return { + sessionId: 1, + videoId: 100, + canonicalTitle: 'Episode 1', + animeTitle: 'Show', + startedAtMs: 1_000_000, + activeWatchedMs: 60_000, + cardsMined: 1, + linesSeen: 10, + lookupCount: 5, + lookupHits: 3, + knownWordsSeen: 5, + // Add any other required fields by reading types/stats.ts. + ...over, + } as SessionSummary; + } + + describe('groupSessionsByVideo', () => { + it('returns an empty array for empty input', () => { + expect(groupSessionsByVideo([])).toEqual([]); + }); + + it('emits a singleton bucket for unique videoIds', () => { + const a = makeSession({ sessionId: 1, videoId: 100 }); + const b = makeSession({ sessionId: 2, videoId: 200 }); + const buckets = groupSessionsByVideo([a, b]); + expect(buckets).toHaveLength(2); + expect(buckets[0]!.sessions).toHaveLength(1); + expect(buckets[1]!.sessions).toHaveLength(1); + }); + + it('combines multiple sessions sharing a videoId into one bucket with summed totals', () => { + const a = makeSession({ + sessionId: 1, + videoId: 100, + startedAtMs: 1_000_000, + activeWatchedMs: 60_000, + cardsMined: 2, + }); + const b = makeSession({ + sessionId: 2, + videoId: 100, + startedAtMs: 2_000_000, + activeWatchedMs: 120_000, + cardsMined: 3, + }); + const buckets = groupSessionsByVideo([a, b]); + expect(buckets).toHaveLength(1); + const bucket = buckets[0]!; + expect(bucket.sessions).toHaveLength(2); + expect(bucket.totalActiveMs).toBe(180_000); + expect(bucket.totalCardsMined).toBe(5); + // Representative is the most-recent session. + expect(bucket.representativeSession.sessionId).toBe(2); + }); + + it('treats sessions with null/missing videoId as singletons keyed by sessionId', () => { + const a = makeSession({ sessionId: 1, videoId: null as unknown as number }); + const b = makeSession({ sessionId: 2, videoId: null as unknown as number }); + const buckets = groupSessionsByVideo([a, b]); + expect(buckets).toHaveLength(2); + expect(buckets[0]!.key).toContain('1'); + expect(buckets[1]!.key).toContain('2'); + }); + }); + ``` + +- [ ] **Step 2: Run the failing tests** + + Run: `bun test stats/src/lib/session-grouping.test.ts` + Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the helper** + + Create `stats/src/lib/session-grouping.ts`: + + ```ts + import type { SessionSummary } from '../types/stats'; + + export interface SessionBucket { + key: string; + videoId: number | null; + sessions: SessionSummary[]; + totalActiveMs: number; + totalCardsMined: number; + representativeSession: SessionSummary; + } + + export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[] { + const byVideo = new Map<string, SessionSummary[]>(); + + for (const session of sessions) { + const hasVideoId = + typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0; + const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`; + const existing = byVideo.get(key); + if (existing) { + existing.push(session); + } else { + byVideo.set(key, [session]); + } + } + + const buckets: SessionBucket[] = []; + for (const [key, group] of byVideo) { + const sorted = [...group].sort((a, b) => b.startedAtMs - a.startedAtMs); + const representative = sorted[0]!; + buckets.push({ + key, + videoId: + typeof representative.videoId === 'number' && representative.videoId > 0 + ? representative.videoId + : null, + sessions: sorted, + totalActiveMs: sorted.reduce((sum, s) => sum + s.activeWatchedMs, 0), + totalCardsMined: sorted.reduce((sum, s) => sum + s.cardsMined, 0), + representativeSession: representative, + }); + } + + // Preserve insertion order — `byVideo` already keeps it (Map insertion order). + return buckets; + } + ``` + +- [ ] **Step 4: Run the tests to verify they pass** + + Run: `bun test stats/src/lib/session-grouping.test.ts` + Expected: PASS. + +- [ ] **Step 5: Typecheck** + + Run: `bun run typecheck:stats` + Expected: succeeds. + +- [ ] **Step 6: Commit** + + ```bash + git add stats/src/lib/session-grouping.ts stats/src/lib/session-grouping.test.ts + git commit -m "feat(stats): add groupSessionsByVideo helper for episode rollups" + ``` + +--- + +## Task 9: Sessions tab — episode rollup UI + +**Files:** +- Modify: `stats/src/components/sessions/SessionsTab.tsx` +- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`) +- Modify: `stats/src/lib/delete-confirm.test.ts` +- Test: extend `stats/src/components/sessions/SessionsTab.test.tsx` if it exists; otherwise add a focused integration test on the new rollup behavior. + +- [ ] **Step 1: Add `confirmBucketDelete` with a failing test** + + In `stats/src/lib/delete-confirm.test.ts`, add: + + ```ts + it('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { + // mock globalThis.confirm to capture the prompt and return true + const calls: string[] = []; + const original = globalThis.confirm; + globalThis.confirm = ((msg: string) => { + calls.push(msg); + return true; + }) as typeof globalThis.confirm; + try { + expect(confirmBucketDelete('My Episode', 3)).toBe(true); + expect(calls[0]).toContain('3'); + expect(calls[0]).toContain('My Episode'); + } finally { + globalThis.confirm = original; + } + }); + ``` + + Update the import line to also import `confirmBucketDelete`. + +- [ ] **Step 2: Run the failing test** + + Run: `bun test stats/src/lib/delete-confirm.test.ts -t 'confirmBucketDelete'` + Expected: FAIL — function doesn't exist. + +- [ ] **Step 3: Add the helper** + + Append to `stats/src/lib/delete-confirm.ts`: + + ```ts + export function confirmBucketDelete(title: string, count: number): boolean { + return globalThis.confirm( + `Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`, + ); + } + ``` + +- [ ] **Step 4: Re-run the test** + + Run: `bun test stats/src/lib/delete-confirm.test.ts` + Expected: PASS. + +- [ ] **Step 5: Add a failing test for the bucket UI** + + In a new or extended `stats/src/components/sessions/SessionsTab.test.tsx`, add a test that: + - Stubs `useSessions` to return three sessions on the same day, two of which share a `videoId`. + - Renders `<SessionsTab />`. + - Asserts the page contains a bucket header for the shared-video pair (e.g. text matching `2 sessions`). + - Asserts the singleton session's title appears once. + - Clicks the bucket header and verifies the underlying two sessions become visible. + +- [ ] **Step 6: Run the failing test** + + Run: `bun test stats/src/components/sessions/SessionsTab.test.tsx -t 'rollup'` + Expected: FAIL — current behavior renders three flat rows. + +- [ ] **Step 7: Restructure `SessionsTab.tsx` to use buckets** + + At the top of the component, import the helper: + + ```tsx + import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping'; + import { confirmBucketDelete } from '../../lib/delete-confirm'; + ``` + + Add a second expanded-state Set keyed by bucket key: + + ```tsx + const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(new Set()); + + const toggleBucket = (key: string) => { + setExpandedBuckets((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + ``` + + Replace the inner day-group loop. Instead of mapping `daySessions.map(...)` directly, run them through `groupSessionsByVideo` and render each bucket. Buckets with one session keep the existing `SessionRow` rendering. Buckets with multiple sessions render a `<SessionBucketRow>` (a small inline component or a JSX block — keep it inline if the file isn't getting too long). + + Skeleton: + + ```tsx + {Array.from(groups.entries()).map(([dayLabel, daySessions]) => { + const buckets = groupSessionsByVideo(daySessions); + return ( + <div key={dayLabel}> + <div className="flex items-center gap-3 mb-2"> + <h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0"> + {dayLabel} + </h3> + <div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" /> + </div> + <div className="space-y-2"> + {buckets.map((bucket) => { + if (bucket.sessions.length === 1) { + const s = bucket.sessions[0]!; + const detailsId = `session-details-${s.sessionId}`; + return ( + <div key={bucket.key}> + <SessionRow + session={s} + isExpanded={expandedId === s.sessionId} + detailsId={detailsId} + onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} + onDelete={() => void handleDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + onNavigateToMediaDetail={onNavigateToMediaDetail} + /> + {expandedId === s.sessionId && ( + <div id={detailsId}> + <SessionDetail session={s} /> + </div> + )} + </div> + ); + } + const isOpen = expandedBuckets.has(bucket.key); + return ( + <div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"> + <button + type="button" + onClick={() => toggleBucket(bucket.key)} + aria-expanded={isOpen} + className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors" + > + <span + aria-hidden="true" + className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`} + > + {'\u25B6'} + </span> + <div className="min-w-0 flex-1"> + <div className="text-sm text-ctp-text truncate"> + {bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'} + </div> + <div className="text-xs text-ctp-overlay2"> + {bucket.sessions.length} sessions ·{' '} + {formatDuration(bucket.totalActiveMs)} ·{' '} + {bucket.totalCardsMined} cards + </div> + </div> + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + void handleDeleteBucket(bucket); + }} + className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors" + title="Delete all sessions in this group" + > + Delete + </button> + </button> + {isOpen && ( + <div className="pl-8 pr-2 pb-2 space-y-2"> + {bucket.sessions.map((s) => { + const detailsId = `session-details-${s.sessionId}`; + return ( + <div key={s.sessionId}> + <SessionRow + session={s} + isExpanded={expandedId === s.sessionId} + detailsId={detailsId} + onToggle={() => + setExpandedId(expandedId === s.sessionId ? null : s.sessionId) + } + onDelete={() => void handleDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + onNavigateToMediaDetail={onNavigateToMediaDetail} + /> + {expandedId === s.sessionId && ( + <div id={detailsId}> + <SessionDetail session={s} /> + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + </div> + ); + })} + ``` + + **Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics: + + ```tsx + <div className="flex items-center"> + <button type="button" onClick={() => toggleBucket(bucket.key)} ...> + ... + </button> + <button type="button" onClick={() => void handleDeleteBucket(bucket)} ...> + Delete + </button> + </div> + ``` + + Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones. + + Add `handleDeleteBucket`: + + ```tsx + const handleDeleteBucket = async (bucket: SessionBucket) => { + const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; + if (!confirmBucketDelete(title, bucket.sessions.length)) return; + setDeleteError(null); + const ids = bucket.sessions.map((s) => s.sessionId); + try { + await apiClient.deleteSessions(ids); + const idSet = new Set(ids); + setVisibleSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); + } + }; + ``` + + Add the `formatDuration` import at the top of the file if not present. + +- [ ] **Step 8: Run the bucket test** + + Run: `bun test stats/src/components/sessions/SessionsTab.test.tsx -t 'rollup'` + Expected: PASS. + +- [ ] **Step 9: Run all sessions tests** + + Run: `bun test stats/src/components/sessions/` + Expected: PASS. + +- [ ] **Step 10: Apply the same rollup to `MediaSessionList.tsx`** + + Read `stats/src/components/library/MediaSessionList.tsx` first. Inside a single video's detail view, all sessions share the same `videoId`, so `groupSessionsByVideo` would always produce one giant bucket. **That's wrong for this view.** Skip the bucket rendering here entirely — `MediaSessionList` still groups by day only. Document this in the commit message: "Rollup intentionally not applied in MediaSessionList because the view is already filtered to a single video." + +- [ ] **Step 11: Typecheck** + + Run: `bun run typecheck:stats` + Expected: succeeds. + +- [ ] **Step 12: Commit** + + ```bash + git add stats/src/components/sessions/SessionsTab.tsx \ + stats/src/components/sessions/SessionsTab.test.tsx \ + stats/src/lib/delete-confirm.ts stats/src/lib/delete-confirm.test.ts + git commit -m "feat(stats): roll up same-episode sessions within a day" + ``` + +--- + +## Task 10: Chart clarity pass + +**Files:** +- Modify: `stats/src/lib/chart-theme.ts` +- Modify: `stats/src/components/trends/TrendChart.tsx` +- Modify: `stats/src/components/trends/StackedTrendChart.tsx` +- Modify: `stats/src/components/overview/WatchTimeChart.tsx` +- Test: create `stats/src/lib/chart-theme.test.ts` if not present + +- [ ] **Step 1: Extend `chart-theme.ts`** + + Replace the file contents with: + + ```ts + export const CHART_THEME = { + tick: '#a5adcb', + tooltipBg: '#363a4f', + tooltipBorder: '#494d64', + tooltipText: '#cad3f5', + tooltipLabel: '#b8c0e0', + barFill: '#8aadf4', + grid: '#494d64', + axisLine: '#494d64', + } as const; + + export const CHART_DEFAULTS = { + height: 160, + tickFontSize: 11, + margin: { top: 8, right: 8, bottom: 0, left: 0 }, + grid: { strokeDasharray: '3 3', vertical: false }, + } as const; + + export const TOOLTIP_CONTENT_STYLE = { + background: CHART_THEME.tooltipBg, + border: `1px solid ${CHART_THEME.tooltipBorder}`, + borderRadius: 6, + color: CHART_THEME.tooltipText, + fontSize: 12, + }; + ``` + +- [ ] **Step 2: Add a snapshot/value test for the new constants** + + Create `stats/src/lib/chart-theme.test.ts`: + + ```ts + import { describe, it, expect } from 'bun:test'; + import { CHART_THEME, CHART_DEFAULTS, TOOLTIP_CONTENT_STYLE } from './chart-theme'; + + describe('chart-theme', () => { + it('exposes a grid color', () => { + expect(CHART_THEME.grid).toBe('#494d64'); + }); + + it('uses 11px ticks for legibility', () => { + expect(CHART_DEFAULTS.tickFontSize).toBe(11); + }); + + it('builds a tooltip content style with border + background', () => { + expect(TOOLTIP_CONTENT_STYLE.background).toBe(CHART_THEME.tooltipBg); + expect(TOOLTIP_CONTENT_STYLE.border).toContain(CHART_THEME.tooltipBorder); + }); + }); + ``` + +- [ ] **Step 3: Run the test** + + Run: `bun test stats/src/lib/chart-theme.test.ts` + Expected: PASS. + +- [ ] **Step 4: Update `TrendChart.tsx` to use the shared theme + add gridlines** + + Replace `stats/src/components/trends/TrendChart.tsx` with: + + ```tsx + import { + BarChart, + Bar, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + ResponsiveContainer, + } from 'recharts'; + import { CHART_THEME, CHART_DEFAULTS, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; + + interface TrendChartProps { + title: string; + data: Array<{ label: string; value: number }>; + color: string; + type: 'bar' | 'line'; + formatter?: (value: number) => string; + onBarClick?: (label: string) => void; + } + + export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) { + const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]); + + return ( + <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> + <h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3> + <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}> + {type === 'bar' ? ( + <BarChart data={data} margin={CHART_DEFAULTS.margin}> + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> + <XAxis + dataKey="label" + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} + tickLine={false} + /> + <YAxis + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} + tickLine={false} + width={32} + tickFormatter={formatter} + /> + <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> + <Bar + dataKey="value" + fill={color} + radius={[2, 2, 0, 0]} + cursor={onBarClick ? 'pointer' : undefined} + onClick={ + onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined + } + /> + </BarChart> + ) : ( + <LineChart data={data} margin={CHART_DEFAULTS.margin}> + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> + <XAxis + dataKey="label" + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} + tickLine={false} + /> + <YAxis + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} + tickLine={false} + width={32} + tickFormatter={formatter} + /> + <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> + <Line dataKey="value" stroke={color} strokeWidth={2} dot={false} /> + </LineChart> + )} + </ResponsiveContainer> + </div> + ); + } + ``` + +- [ ] **Step 5: Update `StackedTrendChart.tsx` and `WatchTimeChart.tsx`** + + Open each file. For each chart container, apply the same recipe: + 1. Import `CartesianGrid` from `recharts`. + 2. Import `CHART_THEME`, `CHART_DEFAULTS`, `TOOLTIP_CONTENT_STYLE` from `'../../lib/chart-theme'`. + 3. Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` as the first child of `<BarChart>`/`<LineChart>`. + 4. Bump `<XAxis tick fontSize>` and `<YAxis tick fontSize>` to `CHART_DEFAULTS.tickFontSize`. + 5. Add `axisLine={{ stroke: CHART_THEME.axisLine }}` to the Y axis. + 6. Replace inline tooltip styles with `contentStyle={TOOLTIP_CONTENT_STYLE}`. + 7. Bump `<ResponsiveContainer height>` from its current value to `CHART_DEFAULTS.height` (160) only if it's currently smaller than 160. Don't shrink anything. + + If either file already exposes formatter props for the Y axis, also pass `tickFormatter={formatter}` to `YAxis` so the unit suffix shows up. + +- [ ] **Step 6: Re-run the chart-theme test plus typecheck** + + Run: `bun test stats/src/lib/chart-theme.test.ts && bun run typecheck:stats` + Expected: PASS + clean. + +- [ ] **Step 7: Sanity-check the overview tab still mounts** + + Run: `bun run build:stats` + Expected: succeeds. + +- [ ] **Step 8: Commit** + + ```bash + git add stats/src/lib/chart-theme.ts stats/src/lib/chart-theme.test.ts \ + stats/src/components/trends/TrendChart.tsx \ + stats/src/components/trends/StackedTrendChart.tsx \ + stats/src/components/overview/WatchTimeChart.tsx + git commit -m "feat(stats): unify chart theme and add gridlines for legibility" + ``` + +--- + +## Task 11: Changelog fragment + +**Files:** +- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md` + +- [ ] **Step 1: Read the existing changelog format** + + Run: `ls changes/ | head -5 && cat changes/$(ls changes/ | head -1)` + Mirror that format exactly. + +- [ ] **Step 2: Write the fragment** + + Create `changes/2026-04-09-stats-dashboard-feedback-pass.md` with content like: + + ```markdown + --- + type: feature + scope: stats + --- + + Stats dashboard polish: + + - Library now collapses multi-episode series under a clickable header. + - Sessions tab rolls up multiple sessions of the same episode within a day. + - Trends gain a 365d range option. + - Episodes can be deleted directly from the library detail view. + - Top 50 vocabulary tightens word/reading spacing. + - Cards deleted from Anki no longer appear in the episode detail card list. + - Trend and watch-time charts gain horizontal gridlines, larger ticks, and a shared theme. + ``` + + Adjust frontmatter keys/values to match whatever existing fragments use. + +- [ ] **Step 3: Validate** + + Run: `bun run changelog:lint && bun run changelog:pr-check` + Expected: PASS. + +- [ ] **Step 4: Commit** + + ```bash + git add changes/2026-04-09-stats-dashboard-feedback-pass.md + git commit -m "docs: add changelog fragment for stats dashboard feedback pass" + ``` + +--- + +## Final verification gate + +Run the project's standard handoff gate: + +- [ ] `bun run typecheck` +- [ ] `bun run typecheck:stats` +- [ ] `bun run test:fast` +- [ ] `bun run test:env` +- [ ] `bun run test:runtime:compat` +- [ ] `bun run build` +- [ ] `bun run test:smoke:dist` +- [ ] `bun run format:check:src` +- [ ] `bun run changelog:lint` +- [ ] `bun run changelog:pr-check` + +If any of those fail, fix the underlying issue and create a new commit (do NOT amend earlier task commits — keep the per-task history clean). + +Then push the branch and open the PR. Suggested PR title: + +``` +Stats dashboard polish: collapsible library, session rollups, 365d trends, chart legibility, episode delete +``` + +Body should link to the spec at `docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md` and summarize each task. + +--- + +## Risk callouts (for the implementing agent) + +- **Anki note-info loading-state guard (Task 5):** double-check the test case for the brief window before `ankiNotesInfo` resolves. Hiding everything during that window would be a regression. +- **Nested button trap (Task 9):** the bucket header must place the toggle button and the delete button as siblings, not nested. Final code must use sibling buttons; the skeleton in the plan flags this. +- **MediaSessionList (Task 9):** rollup is intentionally not applied there. Don't forget the commit message note. +- **`useMediaLibrary` retry behavior (Task 6):** the existing hook auto-refetches when youtube metadata is missing. The new `refresh()` must not break that loop. The `[version]` dependency on the existing `useEffect` triggers a brand-new mount of the inner closure each call, which resets `retryCount` — that's the intended behavior. +- **`bun test` resolves test files relative to repo root.** Always run from `/Users/sudacode/projects/japanese/SubMiner` (the worktree root), not from `stats/`. +- **No file in this plan grows past ~250 lines after edits.** If a file does, that's a signal to extract — flag it on the way through. 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 new file mode 100644 index 00000000..22858bde --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md @@ -0,0 +1,184 @@ +# Library Summary Replaces Per-Day Trends — Design + +**Status:** Draft +**Date:** 2026-04-09 +**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend + +## Problem + +The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day. + +In practice these charts are not useful: + +- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline. +- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow. +- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally. +- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise. + +The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced. + +## Goal + +Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?" + +## Non-goals + +- Changing the "Library — Cumulative" section (stays as-is). +- Changing the "Activity", "Period Trends", or "Patterns" sections. +- Adding a new API endpoint — the existing dashboard endpoint is extended in place. +- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording. +- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab. + +## Solution Overview + +Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of: + +1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending). +2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range. + +Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative. + +## Backend + +### New type + +Add to `stats/src/types/stats.ts` and the backend query module: + +```ts +type LibrarySummaryRow = { + title: string; // display title — anime series, YouTube video title, etc. + watchTimeMin: number; // sum(total_active_min) across the window + videos: number; // distinct video_id count + sessions: number; // session count from imm_sessions + cards: number; // sum(total_cards) + words: number; // sum(total_tokens_seen) + lookups: number; // sum(lookup_count) from imm_sessions + lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0 + firstWatched: number; // min(rollup_day) as epoch day, within the window + lastWatched: number; // max(rollup_day) as epoch day, within the window +}; +``` + +### Query changes in `src/core/services/immersion-tracker/query-trends.ts` + +- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`. +- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos` → `imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1. +- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming. +- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches. +- Emit `lookupsPerHundred: null` when `words == 0`. + +### Removed from API response + +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`). + +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 + +### New component: `stats/src/components/trends/LibrarySummarySection.tsx` + +Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`. + +Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`. + +### Layout + +Replaces `TrendsTab.tsx:224-254`: + +``` +[SectionHeader: "Library — Summary"] +[AnimeVisibilityFilter — unchanged, shared with Cumulative below] +[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall] +[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll] +``` + +Both cards use the existing chart/card wrapper styling. + +### Leaderboard chart + +- 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 (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 + +- Plain HTML `<table>` with Tailwind classes. No new deps. +- Columns, in order: + 1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover. + 2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`. + 3. **Videos** — integer. + 4. **Sessions** — integer. + 5. **Cards** — integer. + 6. **Words** — integer. + 7. **Lookups** — integer. + 8. **Lookups/100w** — one decimal place, `—` when null. + 9. **Date Range** — `Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window. +- Click a column header to sort; click again to reverse. Visual arrow on the active column. +- Numeric columns right-aligned. +- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data"). +- Row hover highlight; no row click action (read-only view). +- Empty state: "No library activity in the selected window." + +### Visibility filter integration + +Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`). + +### `TrendsTab.tsx` changes + +- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals. +- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section. +- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position. +- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place. +- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays. + +## Data flow + +1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`. +2. Response now includes `librarySummary` (sorted by watch time desc). +3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged). +4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied. +5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total). + +## Edge cases + +- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden. +- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing. +- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest. +- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown. +- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip. +- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly. + +## Testing + +### Backend (`query-trends.ts`) + +New unit tests, following the existing pattern: + +1. Empty window returns `librarySummary: []`. +2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window. +3. Multiple titles: rows returned sorted by watch time desc. +4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates. +5. Title with `words == 0`: `lookupsPerHundred` is `null`. +6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity. +7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry. + +### Frontend + +- Existing Trends tab smoke test should continue to pass after wiring. +- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path. + +## Release / docs + +- One fragment in `changes/*.md` summarizing the replacement. +- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation. + +## Open items + +None. diff --git a/docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md b/docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md new file mode 100644 index 00000000..0a8321ab --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md @@ -0,0 +1,347 @@ +# Stats Dashboard Feedback Pass — Design + +Date: 2026-04-09 +Scope: Stats dashboard UX follow-ups from user feedback (items 1–7). +Delivery: **Single PR**, broken into logically scoped commits. + +## Goals + +Address seven concrete pieces of feedback against the Statistics menu: + +1. Library — collapse episodes behind a per-series dropdown. +2. Sessions — roll up multiple sessions of the same episode within a day. +3. Trends — add a 365d range option. +4. Library — delete an episode (video) from its detail view. +5. Vocabulary — tighten spacing between word and reading in the Top 50 table. +6. Episode detail — hide cards whose Anki notes have been deleted. +7. Trend/watch charts — add gridlines, fix tick legibility, unify theming. + +Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively. + +## Files touched (inventory) + +Dashboard (`stats/src/`): +- `components/library/LibraryTab.tsx` — collapsible groups (item 1). +- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4). +- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2). +- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3). +- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5). +- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6). +- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7). +- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`. + +Backend (`src/core/services/`): +- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3). +- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3). +- `stats-server.ts` — passthrough if range validation lives here (check before editing). +- `__tests__/stats-server.test.ts` — 365d coverage (item 3). + +## Commit plan + +One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones. + +1. `feat(stats): add 365d range to trends dashboard` (item 3) +2. `fix(stats): tighten word/reading column in Top 50 table` (item 5) +3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6) +4. `feat(stats): delete episode from library detail view` (item 4) +5. `feat(stats): collapsible series groups in library` (item 1) +6. `feat(stats): roll up same-episode sessions within a day` (item 2) +7. `feat(stats): gridlines and unified theme for trend charts` (item 7) + +Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below. + +--- + +## Item 1 — Library collapsible series groups + +### Current behavior + +`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header. + +### Target behavior + +Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise). + +### Implementation + +- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`. +- Toggle helper: `toggleGroup(key: string)` adds/removes from the set. +- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button. +- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`. +- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative. + +### Tests + +- New `LibraryTab.test.tsx` (if not already present — check first) covering: + - Multi-video group renders collapsed on first mount. + - Single-video group renders expanded on first mount. + - Clicking the header toggles visibility. + - Header summary is visible in both states. + +--- + +## Item 2 — Sessions episode rollup within a day + +### Current behavior + +`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view. + +### Target behavior + +Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting). + +### Implementation + +- New helper in `stats/src/lib/session-grouping.ts`: + ```ts + export interface SessionBucket { + key: string; // videoId as string, or `s-${sessionId}` for singletons + videoId: number | null; + sessions: SessionSummary[]; + totalActiveMs: number; + totalCardsMined: number; + representativeSession: SessionSummary; // most recent, for header display + } + export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[]; + ``` + Sessions missing a `videoId` become singleton buckets. + +- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket: + - `sessions.length === 1`: existing `SessionRow` behavior, unchanged. + - `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header. +- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component. + +### Delete semantics + +- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`). +- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow. + +### Tests + +- `session-grouping.test.ts`: + - Empty input → empty output. + - All unique videos → N singleton buckets. + - Two sessions same videoId → one bucket with correct totals and representative (most recent start time). + - Missing videoId → singleton bucket keyed by sessionId. +- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set. + +--- + +## Item 3 — 365d trends range + +### Backend + +`src/core/services/immersion-tracker/query-trends.ts`: +- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';` +- Add `'365d': 365` to `TREND_DAY_LIMITS`. +- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic. + +`src/core/services/stats-server.ts`: +- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it. + +### Frontend + +- `hooks/useTrends.ts`: widen the `TimeRange` union. +- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`. +- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`. + +### Tests + +- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data. +- `stats-server.test.ts`: ensure the route accepts `range=365d`. +- `api-client.test.ts`: ensure the client emits the new range. + +### Change-specific checks + +- `bun run test:config` is not required here (no schema/defaults change). +- Run `bun run typecheck` + `bun run test:fast`. + +--- + +## Item 4 — Delete episode from library detail + +### Current behavior + +`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab. + +### Target behavior + +A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches. + +### Implementation + +- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided. +- In `MediaDetailView`: + - New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`. + - Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`. +- In `LibraryTab`: + - `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back. + - Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity. + +### Tests + +- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success. +- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it. + +--- + +## Item 5 — Vocabulary word/reading column collapse + +### Current behavior + +`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap. + +### Target behavior + +Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style. + +### Implementation + +- Drop the `<th>Reading</th>` header and cell. +- Word cell becomes: + ```tsx + <td className="py-1.5 pr-3"> + <span className="text-ctp-text font-medium">{w.headword}</span> + {reading && ( + <span className="text-ctp-subtext0 text-xs ml-1.5"> + 【{reading}】 + </span> + )} + </td> + ``` + where `reading = fullReading(w.headword, w.reading)` and differs from `headword`. +- Keep `fullReading` import from `reading-utils`. + +### Tests + +- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert: + - Headword renders. + - Reading renders when different from headword. + - Reading does not render when equal to headword. + +--- + +## Item 6 — Hide Anki-deleted cards in Cards Mined + +### Current behavior + +`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere. + +### Target behavior + +After `ankiNotesInfo` resolves: +- Drop `noteId`s that are not in the resolved map. +- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering. +- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone. + +### Implementation + +- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`. +- Iterate `filteredCardEvents` instead of `cardEvents` in the render. +- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**. + +### Tests + +- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered. + +### Other call sites + +- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter. + +--- + +## Item 7 — Trend/watch chart clarity pass + +### Current behavior + +`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with: +- No `CartesianGrid` → no horizontal reference lines. +- 9px axis ticks → borderline unreadable. +- Height 120 → cramped. +- Tooltip uses raw labels (`04/04` etc.). +- No shared theme object; each chart redefines colors and tooltip styles inline. + +`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers. + +### Target behavior + +All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips. + +### Implementation + +Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break): +```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, +}; +``` + +Apply to each chart: +- Import `CartesianGrid` from recharts. +- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container. +- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`. +- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`. +- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`. +- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`). + +### Unit formatters + +- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.). + +### Tests + +- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable). +- `TrendChart` snapshot/render tests: no regression, gridline element present. + +--- + +## Verification gate + +Before requesting code review, run: + +``` +bun run typecheck +bun run test:fast +bun run test:env +bun run test:runtime:compat # dist-sensitive check for the charts +bun run build +bun run test:smoke:dist +``` + +No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`. + +No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required. + +## Risks and open questions + +- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation. +- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible. +- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review. +- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit. + +## Changelog entry + +User-visible PR → needs a fragment under `changes/*.md`. Suggested title: +`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.` diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 1ce71916..23190614 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -166,14 +166,20 @@ const TRENDS_DASHBOARD = { ratios: { lookupsPerHundred: [{ label: 'Mar 1', value: 5 }], }, - animePerDay: { - episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], - watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], - cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], - words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }], - lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }], - lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], - }, + 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, + }, + ], animeCumulative: { watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], @@ -598,7 +604,23 @@ describe('stats server API routes', () => { const body = await res.json(); assert.deepEqual(seenArgs, ['90d', 'month']); assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); - assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime); + assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); + }); + + it('GET /api/stats/trends/dashboard accepts 365d range', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getTrendsDashboard: async (...args: unknown[]) => { + seenArgs = args; + return TRENDS_DASHBOARD; + }, + }), + ); + + const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month'); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, ['365d', 'month']); }); it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => { diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 6cbf5841..fe93e508 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -488,7 +488,7 @@ export class ImmersionTrackerService { } async getTrendsDashboard( - range: '7d' | '30d' | '90d' | 'all' = '30d', + range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d', groupBy: 'day' | 'month' = 'day', ): Promise<unknown> { return getTrendsDashboard(this.db, range, groupBy); diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index f131048e..2d36ffca 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -687,7 +687,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => { assert.equal(dashboard.progress.watchTime[1]?.value, 75); assert.equal(dashboard.progress.lookups[1]?.value, 18); assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1)); - assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime'); + assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime'); assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75); assert.equal( dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0), @@ -835,6 +835,65 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => { } }); +test('getTrendsDashboard supports 365d range and caps day buckets at 365', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + withMockNowMs('1772395200000', () => { + try { + ensureSchema(db); + + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', { + canonicalTitle: '365d Trends', + sourcePath: '/tmp/365d-trends.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: '365d Trends', + canonicalTitle: '365d Trends', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: '365d-trends.mkv', + parsedTitle: '365d Trends', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + + const insertDailyRollup = db.prepare( + ` + INSERT INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + // Seed 400 distinct rollup days so we can prove the 365d range caps at 365. + const latestRollupDay = 20513; + const createdAtMs = '1772395200000'; + for (let offset = 0; offset < 400; offset += 1) { + const rollupDay = latestRollupDay - offset; + insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs); + } + + const dashboard = getTrendsDashboard(db, '365d', 'day'); + + assert.equal(dashboard.activity.watchTime.length, 365); + } finally { + db.close(); + cleanupDbPath(dbPath); + } + }); +}); + test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -3666,3 +3725,224 @@ test('deleteSession removes zero-session media from library and trends', () => { cleanupDbPath(dbPath); } }); + +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); + } +}); + +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); + } +}); diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts index 2a0f3eb2..f521886e 100644 --- a/src/core/services/immersion-tracker/query-trends.ts +++ b/src/core/services/immersion-tracker/query-trends.ts @@ -13,7 +13,7 @@ import { } from './query-shared'; import { getDailyRollups, getMonthlyRollups } from './query-sessions'; -type TrendRange = '7d' | '30d' | '90d' | 'all'; +type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all'; type TrendGroupBy = 'day' | 'month'; interface TrendChartPoint { @@ -27,6 +27,19 @@ interface TrendPerAnimePoint { value: number; } +export interface LibrarySummaryRow { + title: string; + watchTimeMin: number; + videos: number; + sessions: number; + cards: number; + words: number; + lookups: number; + lookupsPerHundred: number | null; + firstWatched: number; + lastWatched: number; +} + interface TrendSessionMetricRow { startedAtMs: number; epochDay: number; @@ -61,14 +74,6 @@ export interface TrendsDashboardQueryResult { ratios: { lookupsPerHundred: TrendChartPoint[]; }; - animePerDay: { - episodes: TrendPerAnimePoint[]; - watchTime: TrendPerAnimePoint[]; - cards: TrendPerAnimePoint[]; - words: TrendPerAnimePoint[]; - lookups: TrendPerAnimePoint[]; - lookupsPerHundred: TrendPerAnimePoint[]; - }; animeCumulative: { watchTime: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[]; @@ -79,12 +84,14 @@ export interface TrendsDashboardQueryResult { watchTimeByDayOfWeek: TrendChartPoint[]; watchTimeByHour: TrendChartPoint[]; }; + librarySummary: LibrarySummaryRow[]; } const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = { '7d': 7, '30d': 30, '90d': 90, + '365d': 365, }; const MONTH_NAMES = [ @@ -300,61 +307,6 @@ function buildLookupsPerHundredWords( }); } -function buildPerAnimeFromSessions( - sessions: TrendSessionMetricRow[], - getValue: (session: TrendSessionMetricRow) => number, -): TrendPerAnimePoint[] { - const byAnime = new Map<string, Map<number, number>>(); - - for (const session of sessions) { - const animeTitle = resolveTrendAnimeTitle(session); - const epochDay = session.epochDay; - const dayMap = byAnime.get(animeTitle) ?? new Map(); - dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session)); - byAnime.set(animeTitle, dayMap); - } - - const result: TrendPerAnimePoint[] = []; - for (const [animeTitle, dayMap] of byAnime) { - for (const [epochDay, value] of dayMap) { - result.push({ epochDay, animeTitle, value }); - } - } - return result; -} - -function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): TrendPerAnimePoint[] { - const lookups = new Map<string, Map<number, number>>(); - const words = new Map<string, Map<number, number>>(); - - for (const session of sessions) { - const animeTitle = resolveTrendAnimeTitle(session); - const epochDay = session.epochDay; - - const lookupMap = lookups.get(animeTitle) ?? new Map(); - lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount); - lookups.set(animeTitle, lookupMap); - - const wordMap = words.get(animeTitle) ?? new Map(); - wordMap.set(epochDay, (wordMap.get(epochDay) ?? 0) + getTrendSessionWordCount(session)); - words.set(animeTitle, wordMap); - } - - const result: TrendPerAnimePoint[] = []; - for (const [animeTitle, dayMap] of lookups) { - const wordMap = words.get(animeTitle) ?? new Map(); - for (const [epochDay, lookupCount] of dayMap) { - const wordCount = wordMap.get(epochDay) ?? 0; - result.push({ - epochDay, - animeTitle, - value: wordCount > 0 ? +((lookupCount / wordCount) * 100).toFixed(1) : 0, - }); - } - } - return result; -} - function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] { const byAnime = new Map<string, Map<number, number>>(); const allDays = new Set<number>(); @@ -390,6 +342,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi return result; } +function buildLibrarySummary( + rollups: ImmersionSessionRollupRow[], + sessions: TrendSessionMetricRow[], + titlesByVideoId: Map<number, string>, +): LibrarySummaryRow[] { + type Accum = { + watchTimeMin: number; + videos: Set<number>; + cards: number; + words: number; + firstWatched: number; + lastWatched: number; + sessions: number; + lookups: number; + }; + + const byTitle = new Map<string, Accum>(); + + const ensure = (title: string): Accum => { + const existing = byTitle.get(title); + if (existing) return existing; + const created: Accum = { + watchTimeMin: 0, + videos: new Set<number>(), + 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; +} + function getVideoAnimeTitleMap( db: DatabaseSync, videoIds: Array<number | null>, @@ -662,8 +697,6 @@ export function getTrendsDashboard( titlesByVideoId, (rollup) => rollup.totalTokensSeen, ), - lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount), - lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions), }; return { @@ -690,7 +723,6 @@ export function getTrendsDashboard( ratios: { lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), }, - animePerDay, animeCumulative: { watchTime: buildCumulativePerAnime(animePerDay.watchTime), episodes: buildCumulativePerAnime(animePerDay.episodes), @@ -701,5 +733,6 @@ export function getTrendsDashboard( watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), watchTimeByHour: buildWatchTimeByHour(sessions), }, + librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), }; } diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 52185877..cdaeef01 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -30,8 +30,10 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); } -function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' { - return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d'; +function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' { + return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all' + ? raw + : '30d'; } function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { diff --git a/stats/src/components/anime/AnimeTab.tsx b/stats/src/components/anime/AnimeTab.tsx index 06bcf924..26d69ac2 100644 --- a/stats/src/components/anime/AnimeTab.tsx +++ b/stats/src/components/anime/AnimeTab.tsx @@ -93,7 +93,7 @@ export function AnimeTab({ <div className="flex items-center gap-3"> <input type="text" - placeholder="Search anime..." + placeholder="Search library..." value={search} onChange={(e) => setSearch(e.target.value)} className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue" @@ -125,12 +125,12 @@ export function AnimeTab({ ))} </div> <div className="text-xs text-ctp-overlay2 shrink-0"> - {filtered.length} anime · {formatDuration(totalMs)} + {filtered.length} titles · {formatDuration(totalMs)} </div> </div> {filtered.length === 0 ? ( - <div className="text-sm text-ctp-overlay2 p-4">No anime found</div> + <div className="text-sm text-ctp-overlay2 p-4">No titles found</div> ) : ( <div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}> {filtered.map((item) => ( diff --git a/stats/src/components/anime/EpisodeDetail.test.tsx b/stats/src/components/anime/EpisodeDetail.test.tsx new file mode 100644 index 00000000..634fea1f --- /dev/null +++ b/stats/src/components/anime/EpisodeDetail.test.tsx @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { filterCardEvents } from './EpisodeDetail'; +import type { EpisodeCardEvent } from '../../types/stats'; + +function makeEvent(over: Partial<EpisodeCardEvent> & { eventId: number }): EpisodeCardEvent { + return { + sessionId: 1, + tsMs: 0, + cardsDelta: 1, + noteIds: [], + ...over, + }; +} + +test('filterCardEvents: before load, returns all events unchanged', () => { + const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); + const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); + const noteInfos = new Map(); // empty — simulates pre-load state + const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ false); + assert.equal(result.length, 2, 'should return both events before load'); + assert.deepEqual(result[0]?.noteIds, [101]); + assert.deepEqual(result[1]?.noteIds, [102]); +}); + +test('filterCardEvents: after load, drops noteIds not in noteInfos', () => { + const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); // survives + const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); // deleted from Anki + const noteInfos = new Map([[101, { noteId: 101, expression: '食べる' }]]); + const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ true); + assert.equal(result.length, 1, 'should drop event whose noteId was deleted from Anki'); + assert.equal(result[0]?.eventId, 1); + assert.deepEqual(result[0]?.noteIds, [101]); +}); + +test('filterCardEvents: after load, legacy rollup events (empty noteIds, positive cardsDelta) are kept', () => { + const rollup = makeEvent({ eventId: 3, noteIds: [], cardsDelta: 5 }); + const noteInfos = new Map<number, { noteId: number; expression: string }>(); + const result = filterCardEvents([rollup], noteInfos, true); + assert.equal(result.length, 1, 'legacy rollup event should survive filtering'); + assert.equal(result[0]?.cardsDelta, 5); +}); + +test('filterCardEvents: after load, event with multiple noteIds keeps surviving ones', () => { + const ev = makeEvent({ eventId: 4, noteIds: [201, 202, 203] }); + const noteInfos = new Map([ + [201, { noteId: 201, expression: 'A' }], + [203, { noteId: 203, expression: 'C' }], + ]); + const result = filterCardEvents([ev], noteInfos, true); + assert.equal(result.length, 1, 'event with surviving noteIds should be kept'); + assert.deepEqual(result[0]?.noteIds, [201, 203], 'only surviving noteIds should remain'); +}); + +test('filterCardEvents: after load, event where all noteIds deleted is dropped', () => { + const ev = makeEvent({ eventId: 5, noteIds: [301, 302] }); + const noteInfos = new Map<number, { noteId: number; expression: string }>(); + const result = filterCardEvents([ev], noteInfos, true); + assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped'); +}); diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index 5415f6c2..408b79d5 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -16,10 +16,32 @@ interface NoteInfo { expression: string; } +export function filterCardEvents( + cardEvents: EpisodeDetailData['cardEvents'], + noteInfos: Map<number, NoteInfo>, + noteInfosLoaded: boolean, +): EpisodeDetailData['cardEvents'] { + if (!noteInfosLoaded) return cardEvents; + return cardEvents + .map((ev) => { + // Legacy rollup events: no noteIds, just a cardsDelta count — keep as-is. + if (ev.noteIds.length === 0) return ev; + const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id)); + return { ...ev, noteIds: survivingNoteIds }; + }) + .filter((ev, i) => { + // If the event originally had noteIds, only keep it if some survived. + if ((cardEvents[i]?.noteIds.length ?? 0) > 0) return ev.noteIds.length > 0; + // Legacy rollup event (originally no noteIds): keep if it has a positive delta. + return ev.cardsDelta > 0; + }); +} + export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) { const [data, setData] = useState<EpisodeDetailData | null>(null); const [loading, setLoading] = useState(true); const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map()); + const [noteInfosLoaded, setNoteInfosLoaded] = useState(false); useEffect(() => { let cancelled = false; @@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) map.set(note.noteId, { noteId: note.noteId, expression: expr }); } setNoteInfos(map); + setNoteInfosLoaded(true); }) - .catch((err) => console.warn('Failed to fetch Anki note info:', err)); + .catch((err) => { + console.warn('Failed to fetch Anki note info:', err); + if (!cancelled) setNoteInfosLoaded(true); + }); + } else { + if (!cancelled) setNoteInfosLoaded(true); } }) .catch(() => { @@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) const { sessions, cardEvents } = data; + const filteredCardEvents = filterCardEvents(cardEvents, noteInfos, noteInfosLoaded); + + const hiddenCardCount = noteInfosLoaded + ? cardEvents.reduce((sum, ev) => { + if (ev.noteIds.length === 0) return sum; + const surviving = ev.noteIds.filter((id) => noteInfos.has(id)); + return sum + (ev.noteIds.length - surviving.length); + }, 0) + : 0; + return ( <div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg"> {sessions.length > 0 && ( @@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) </div> )} - {cardEvents.length > 0 && ( + {filteredCardEvents.length > 0 && ( <div className="p-3 border-b border-ctp-surface1"> <h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4> <div className="space-y-1.5"> - {cardEvents.map((ev) => ( + {filteredCardEvents.map((ev) => ( <div key={ev.eventId} className="flex items-center gap-2 text-xs"> <span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span> {ev.noteIds.length > 0 ? ( @@ -144,6 +182,12 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) </div> ))} </div> + {hiddenCardCount > 0 && ( + <div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic"> + {hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from + Anki) + </div> + )} </div> )} diff --git a/stats/src/components/library/LibraryTab.tsx b/stats/src/components/library/LibraryTab.tsx deleted file mode 100644 index e058062f..00000000 --- a/stats/src/components/library/LibraryTab.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useMediaLibrary } from '../../hooks/useMediaLibrary'; -import { formatDuration, formatNumber } from '../../lib/formatters'; -import { - groupMediaLibraryItems, - summarizeMediaLibraryGroups, -} from '../../lib/media-library-grouping'; -import { CoverImage } from './CoverImage'; -import { MediaCard } from './MediaCard'; -import { MediaDetailView } from './MediaDetailView'; - -interface LibraryTabProps { - onNavigateToSession: (sessionId: number) => void; -} - -export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { - const { media, loading, error } = useMediaLibrary(); - const [search, setSearch] = useState(''); - const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null); - - const filtered = useMemo(() => { - if (!search.trim()) return media; - const q = search.toLowerCase(); - return media.filter((m) => { - const haystacks = [ - m.canonicalTitle, - m.videoTitle, - m.channelName, - m.uploaderId, - m.channelId, - ].filter(Boolean); - return haystacks.some((value) => value!.toLowerCase().includes(q)); - }); - }, [media, search]); - const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]); - const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]); - - if (selectedVideoId !== null) { - return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />; - } - - if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; - if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; - - return ( - <div className="space-y-4"> - <div className="flex items-center gap-3"> - <input - type="text" - placeholder="Search titles..." - value={search} - onChange={(e) => setSearch(e.target.value)} - className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue" - /> - <div className="text-xs text-ctp-overlay2 shrink-0"> - {grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video - {summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)} - </div> - </div> - - {filtered.length === 0 ? ( - <div className="text-sm text-ctp-overlay2 p-4">No media found</div> - ) : ( - <div className="space-y-6"> - {grouped.map((group) => ( - <section - key={group.key} - className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden" - > - <div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40"> - <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"> - {group.channelUrl ? ( - <a - href={group.channelUrl} - target="_blank" - rel="noreferrer" - className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors" - > - {group.title} - </a> - ) : ( - <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> - </div> - <div 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> - ))} - </div> - )} - </div> - ); -} diff --git a/stats/src/components/library/MediaDetailView.test.tsx b/stats/src/components/library/MediaDetailView.test.tsx index accce1ed..6a8c47a9 100644 --- a/stats/src/components/library/MediaDetailView.test.tsx +++ b/stats/src/components/library/MediaDetailView.test.tsx @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { getRelatedCollectionLabel } from './MediaDetailView'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createElement } from 'react'; +import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView'; test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => { assert.equal( @@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () => 'View Anime', ); }); + +test('buildDeleteEpisodeHandler calls deleteVideo then onBack when confirm returns true', async () => { + let deletedVideoId: number | null = null; + let onBackCalled = false; + + const fakeApiClient = { + deleteVideo: async (id: number) => { + deletedVideoId = id; + }, + }; + + const fakeConfirm = (_title: string) => true; + + const handler = buildDeleteEpisodeHandler({ + videoId: 42, + title: 'Test Episode', + apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> }, + confirmFn: fakeConfirm, + onBack: () => { + onBackCalled = true; + }, + setDeleteError: () => {}, + }); + + await handler(); + assert.equal(deletedVideoId, 42); + assert.equal(onBackCalled, true); +}); + +test('buildDeleteEpisodeHandler does nothing when confirm returns false', async () => { + let deletedVideoId: number | null = null; + let onBackCalled = false; + + const fakeApiClient = { + deleteVideo: async (id: number) => { + deletedVideoId = id; + }, + }; + + const fakeConfirm = (_title: string) => false; + + const handler = buildDeleteEpisodeHandler({ + videoId: 42, + title: 'Test Episode', + apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> }, + confirmFn: fakeConfirm, + onBack: () => { + onBackCalled = true; + }, + setDeleteError: () => {}, + }); + + await handler(); + assert.equal(deletedVideoId, null); + assert.equal(onBackCalled, false); +}); + +test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () => { + let capturedError: string | null = null; + + const fakeApiClient = { + deleteVideo: async (_id: number) => { + throw new Error('Network failure'); + }, + }; + + const fakeConfirm = (_title: string) => true; + + const handler = buildDeleteEpisodeHandler({ + videoId: 42, + title: 'Test Episode', + apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> }, + confirmFn: fakeConfirm, + onBack: () => {}, + setDeleteError: (msg) => { + capturedError = msg; + }, + }); + + await handler(); + assert.equal(capturedError, 'Network failure'); +}); diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx index 4f353c93..8c07a286 100644 --- a/stats/src/components/library/MediaDetailView.tsx +++ b/stats/src/components/library/MediaDetailView.tsx @@ -1,12 +1,48 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMediaDetail } from '../../hooks/useMediaDetail'; import { apiClient } from '../../lib/api-client'; -import { confirmSessionDelete } from '../../lib/delete-confirm'; +import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm'; import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { MediaHeader } from './MediaHeader'; import { MediaSessionList } from './MediaSessionList'; import type { MediaDetailData, SessionSummary } from '../../types/stats'; +interface DeleteEpisodeHandlerOptions { + videoId: number; + title: string; + apiClient: { deleteVideo: (id: number) => Promise<void> }; + confirmFn: (title: string) => boolean; + onBack: () => void; + setDeleteError: (msg: string | null) => void; + /** + * Ref used to guard against reentrant delete calls synchronously. When set, + * a subsequent invocation while the previous request is still pending is + * ignored so clicks during the await window can't trigger duplicate deletes. + */ + isDeletingRef?: { current: boolean }; + /** Optional React state setter so the UI can reflect the pending state. */ + setIsDeleting?: (value: boolean) => void; +} + +export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> { + return async () => { + if (opts.isDeletingRef?.current) return; + if (!opts.confirmFn(opts.title)) return; + if (opts.isDeletingRef) opts.isDeletingRef.current = true; + opts.setIsDeleting?.(true); + opts.setDeleteError(null); + try { + await opts.apiClient.deleteVideo(opts.videoId); + opts.onBack(); + } catch (err) { + opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.'); + } finally { + if (opts.isDeletingRef) opts.isDeletingRef.current = false; + opts.setIsDeleting?.(false); + } + }; +} + export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string { if (detail?.channelName?.trim()) { return 'View Channel'; @@ -35,6 +71,8 @@ export function MediaDetailView({ const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null); const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null); + const [isDeletingEpisode, setIsDeletingEpisode] = useState(false); + const isDeletingEpisodeRef = useRef(false); useEffect(() => { setLocalSessions(data?.sessions ?? null); @@ -79,6 +117,17 @@ export function MediaDetailView({ } }; + const handleDeleteEpisode = buildDeleteEpisodeHandler({ + videoId, + title: detail.canonicalTitle, + apiClient, + confirmFn: confirmEpisodeDelete, + onBack, + setDeleteError, + isDeletingRef: isDeletingEpisodeRef, + setIsDeleting: setIsDeletingEpisode, + }); + return ( <div className="space-y-4"> <div className="flex items-center justify-between"> @@ -99,7 +148,11 @@ export function MediaDetailView({ </button> ) : null} </div> - <MediaHeader detail={detail} /> + <MediaHeader + detail={detail} + onDeleteEpisode={handleDeleteEpisode} + isDeletingEpisode={isDeletingEpisode} + /> {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null} <MediaSessionList sessions={sessions} diff --git a/stats/src/components/library/MediaHeader.tsx b/stats/src/components/library/MediaHeader.tsx index 5dfda272..aa9f14ac 100644 --- a/stats/src/components/library/MediaHeader.tsx +++ b/stats/src/components/library/MediaHeader.tsx @@ -12,9 +12,16 @@ interface MediaHeaderProps { totalUniqueWords: number; knownWordCount: number; } | null; + onDeleteEpisode?: () => void; + isDeletingEpisode?: boolean; } -export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) { +export function MediaHeader({ + detail, + initialKnownWordsSummary = null, + onDeleteEpisode, + isDeletingEpisode = false, +}: MediaHeaderProps) { const knownTokenRate = detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; const avgSessionMs = @@ -50,7 +57,21 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe className="w-32 h-44 rounded-lg shrink-0" /> <div className="flex-1 min-w-0"> - <h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2> + <div className="flex items-start justify-between gap-2"> + <h2 className="min-w-0 flex-1 text-lg font-bold text-ctp-text truncate"> + {detail.canonicalTitle} + </h2> + {onDeleteEpisode != null ? ( + <button + type="button" + onClick={onDeleteEpisode} + disabled={isDeletingEpisode} + className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed" + > + {isDeletingEpisode ? 'Deleting...' : 'Delete Episode'} + </button> + ) : null} + </div> {detail.channelName ? ( <div className="mt-1 text-sm text-ctp-subtext1 truncate"> {detail.channelUrl ? ( diff --git a/stats/src/components/overview/HeroStats.tsx b/stats/src/components/overview/HeroStats.tsx index 9c11f182..98015ae7 100644 --- a/stats/src/components/overview/HeroStats.tsx +++ b/stats/src/components/overview/HeroStats.tsx @@ -36,7 +36,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) { /> <StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" /> <StatCard - label="Active Anime" + label="Active Titles" value={formatNumber(summary.activeAnimeCount)} color="text-ctp-mauve" /> diff --git a/stats/src/components/overview/TrackingSnapshot.tsx b/stats/src/components/overview/TrackingSnapshot.tsx index dd8bde0b..4319002a 100644 --- a/stats/src/components/overview/TrackingSnapshot.tsx +++ b/stats/src/components/overview/TrackingSnapshot.tsx @@ -71,7 +71,7 @@ export function TrackingSnapshot({ </div> </div> </Tooltip> - <Tooltip text="Total unique episodes (videos) watched across all anime"> + <Tooltip text="Total unique videos watched across all titles in your library"> <div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue"> @@ -79,9 +79,9 @@ export function TrackingSnapshot({ </div> </div> </Tooltip> - <Tooltip text="Number of anime series fully completed"> + <Tooltip text="Number of titles fully completed"> <div className="rounded-lg bg-ctp-surface1/60 p-3"> - <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div> + <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Titles</div> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire"> {formatNumber(summary.totalAnimeCompleted)} </div> diff --git a/stats/src/components/overview/WatchTimeChart.tsx b/stats/src/components/overview/WatchTimeChart.tsx index b8f40dfe..7a5d1d92 100644 --- a/stats/src/components/overview/WatchTimeChart.tsx +++ b/stats/src/components/overview/WatchTimeChart.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; +import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { epochDayToDate } from '../../lib/formatters'; -import { CHART_THEME } from '../../lib/chart-theme'; +import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; import type { DailyRollup } from '../../types/stats'; interface WatchTimeChartProps { @@ -52,28 +52,23 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) { ))} </div> </div> - <ResponsiveContainer width="100%" height={160}> - <BarChart data={chartData}> + <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}> + <BarChart data={chartData} margin={CHART_DEFAULTS.margin}> + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> <XAxis dataKey="date" - tick={{ fontSize: 10, fill: CHART_THEME.tick }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} /> <YAxis - tick={{ fontSize: 10, fill: CHART_THEME.tick }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} - width={30} + width={32} /> <Tooltip - contentStyle={{ - background: CHART_THEME.tooltipBg, - border: `1px solid ${CHART_THEME.tooltipBorder}`, - borderRadius: 6, - color: CHART_THEME.tooltipText, - fontSize: 12, - }} + contentStyle={TOOLTIP_CONTENT_STYLE} labelStyle={{ color: CHART_THEME.tooltipLabel }} formatter={formatActiveMinutes} /> diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index 2eb0263d..2c8d776e 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) { const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); const hasKnownWords = knownWordsMap.size > 0; - const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } = + const { cardEvents, yomitanLookupEvents, pauseRegions, markers } = buildSessionChartEvents(events); const lookupRate = buildLookupRateDisplay( session.yomitanLookupCount, getSessionDisplayWordCount(session), ); const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; - const seekCount = seekEvents.length; const cardEventCount = cardEvents.length; const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey); const activeMarker = useMemo<SessionChartMarker | null>( @@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) { sorted={sorted} knownWordsMap={knownWordsMap} cardEvents={cardEvents} - seekEvents={seekEvents} yomitanLookupEvents={yomitanLookupEvents} pauseRegions={pauseRegions} markers={markers} @@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) { loadingNoteIds={loadingNoteIds} onOpenNote={handleOpenNote} pauseCount={pauseCount} - seekCount={seekCount} cardEventCount={cardEventCount} lookupRate={lookupRate} session={session} @@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) { <FallbackView sorted={sorted} cardEvents={cardEvents} - seekEvents={seekEvents} yomitanLookupEvents={yomitanLookupEvents} pauseRegions={pauseRegions} markers={markers} @@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) { loadingNoteIds={loadingNoteIds} onOpenNote={handleOpenNote} pauseCount={pauseCount} - seekCount={seekCount} cardEventCount={cardEventCount} lookupRate={lookupRate} session={session} @@ -280,7 +275,6 @@ function RatioView({ sorted, knownWordsMap, cardEvents, - seekEvents, yomitanLookupEvents, pauseRegions, markers, @@ -292,7 +286,6 @@ function RatioView({ loadingNoteIds, onOpenNote, pauseCount, - seekCount, cardEventCount, lookupRate, session, @@ -300,7 +293,6 @@ function RatioView({ sorted: TimelineEntry[]; knownWordsMap: Map<number, number>; cardEvents: SessionEvent[]; - seekEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[]; pauseRegions: Array<{ startMs: number; endMs: number }>; markers: SessionChartMarker[]; @@ -312,7 +304,6 @@ function RatioView({ loadingNoteIds: Set<number>; onOpenNote: (noteId: number) => void; pauseCount: number; - seekCount: number; cardEventCount: number; lookupRate: ReturnType<typeof buildLookupRateDisplay>; session: SessionSummary; @@ -450,22 +441,6 @@ function RatioView({ /> ))} - {seekEvents.map((e, i) => { - const isBackward = e.eventType === EventType.SEEK_BACKWARD; - const stroke = isBackward ? '#f5bde6' : '#8bd5ca'; - return ( - <ReferenceLine - key={`seek-${i}`} - yAxisId="pct" - x={e.tsMs} - stroke={stroke} - strokeWidth={1.5} - strokeOpacity={0.75} - strokeDasharray="4 3" - /> - ); - })} - {/* Yomitan lookup markers */} {yomitanLookupEvents.map((e, i) => ( <ReferenceLine @@ -549,7 +524,6 @@ function RatioView({ <StatsBar hasKnownWords pauseCount={pauseCount} - seekCount={seekCount} cardEventCount={cardEventCount} session={session} lookupRate={lookupRate} @@ -563,7 +537,6 @@ function RatioView({ function FallbackView({ sorted, cardEvents, - seekEvents, yomitanLookupEvents, pauseRegions, markers, @@ -575,14 +548,12 @@ function FallbackView({ loadingNoteIds, onOpenNote, pauseCount, - seekCount, cardEventCount, lookupRate, session, }: { sorted: TimelineEntry[]; cardEvents: SessionEvent[]; - seekEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[]; pauseRegions: Array<{ startMs: number; endMs: number }>; markers: SessionChartMarker[]; @@ -594,7 +565,6 @@ function FallbackView({ loadingNoteIds: Set<number>; onOpenNote: (noteId: number) => void; pauseCount: number; - seekCount: number; cardEventCount: number; lookupRate: ReturnType<typeof buildLookupRateDisplay>; session: SessionSummary; @@ -680,20 +650,6 @@ function FallbackView({ strokeOpacity={0.8} /> ))} - {seekEvents.map((e, i) => { - const isBackward = e.eventType === EventType.SEEK_BACKWARD; - const stroke = isBackward ? '#f5bde6' : '#8bd5ca'; - return ( - <ReferenceLine - key={`seek-${i}`} - x={e.tsMs} - stroke={stroke} - strokeWidth={1.5} - strokeOpacity={0.75} - strokeDasharray="4 3" - /> - ); - })} {yomitanLookupEvents.map((e, i) => ( <ReferenceLine key={`yomitan-${i}`} @@ -735,7 +691,6 @@ function FallbackView({ <StatsBar hasKnownWords={false} pauseCount={pauseCount} - seekCount={seekCount} cardEventCount={cardEventCount} session={session} lookupRate={lookupRate} @@ -749,14 +704,12 @@ function FallbackView({ function StatsBar({ hasKnownWords, pauseCount, - seekCount, cardEventCount, session, lookupRate, }: { hasKnownWords: boolean; pauseCount: number; - seekCount: number; cardEventCount: number; session: SessionSummary; lookupRate: ReturnType<typeof buildLookupRateDisplay>; @@ -791,12 +744,7 @@ function StatsBar({ {pauseCount !== 1 ? 's' : ''} </span> )} - {seekCount > 0 && ( - <span className="text-ctp-overlay2"> - <span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''} - </span> - )} - {(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>} + {pauseCount > 0 && <span className="text-ctp-surface2">|</span>} {/* Group 3: Learning events */} <span className="flex items-center gap-1.5"> diff --git a/stats/src/components/sessions/SessionEventOverlay.tsx b/stats/src/components/sessions/SessionEventOverlay.tsx index 87322622..e1eee596 100644 --- a/stats/src/components/sessions/SessionEventOverlay.tsx +++ b/stats/src/components/sessions/SessionEventOverlay.tsx @@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string { switch (marker.kind) { case 'pause': return '||'; - case 'seek': - return marker.direction === 'backward' ? '<<' : '>>'; case 'card': return '\u26CF'; } @@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string; switch (marker.kind) { case 'pause': return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' }; - case 'seek': - return marker.direction === 'backward' - ? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' } - : { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' }; case 'card': return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' }; } diff --git a/stats/src/components/sessions/SessionEventPopover.test.tsx b/stats/src/components/sessions/SessionEventPopover.test.tsx index 801d5dd2..eb1b6c68 100644 --- a/stats/src/components/sessions/SessionEventPopover.test.tsx +++ b/stats/src/components/sessions/SessionEventPopover.test.tsx @@ -41,35 +41,6 @@ test('SessionEventPopover renders formatted card-mine details with fetched note assert.match(markup, /Open in Anki/); }); -test('SessionEventPopover renders seek metadata compactly', () => { - const marker: SessionChartMarker = { - key: 'seek-3000', - kind: 'seek', - anchorTsMs: 3_000, - eventTsMs: 3_000, - direction: 'backward', - fromMs: 5_000, - toMs: 1_500, - }; - - const markup = renderToStaticMarkup( - <SessionEventPopover - marker={marker} - noteInfos={new Map()} - loading={false} - pinned={false} - onTogglePinned={() => {}} - onClose={() => {}} - onOpenNote={() => {}} - />, - ); - - assert.match(markup, /Seek backward/); - assert.match(markup, /5\.0s/); - assert.match(markup, /1\.5s/); - assert.match(markup, /3\.5s/); -}); - test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => { const marker: SessionChartMarker = { key: 'card-9000', diff --git a/stats/src/components/sessions/SessionEventPopover.tsx b/stats/src/components/sessions/SessionEventPopover.tsx index b9e3090b..2e397b1a 100644 --- a/stats/src/components/sessions/SessionEventPopover.tsx +++ b/stats/src/components/sessions/SessionEventPopover.tsx @@ -31,18 +31,12 @@ export function SessionEventPopover({ onClose, onOpenNote, }: SessionEventPopoverProps) { - const seekDurationLabel = - marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null - ? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's') - : null; - return ( <div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm"> <div className="mb-2 flex items-start justify-between gap-3"> <div> <div className="text-xs font-semibold text-ctp-text"> {marker.kind === 'pause' && 'Paused'} - {marker.kind === 'seek' && `Seek ${marker.direction}`} {marker.kind === 'card' && 'Card mined'} </div> <div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div> @@ -72,7 +66,6 @@ export function SessionEventPopover({ ) : null} <div className="text-sm"> {marker.kind === 'pause' && '||'} - {marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')} {marker.kind === 'card' && '\u26CF'} </div> </div> @@ -84,19 +77,6 @@ export function SessionEventPopover({ </div> )} - {marker.kind === 'seek' && ( - <div className="space-y-1 text-xs text-ctp-subtext0"> - <div> - From{' '} - <span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '} - to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span> - </div> - <div> - Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span> - </div> - </div> - )} - {marker.kind === 'card' && ( <div className="space-y-2"> <div className="text-xs text-ctp-cards-mined"> diff --git a/stats/src/components/sessions/SessionRow.tsx b/stats/src/components/sessions/SessionRow.tsx index b3aaea99..db03a77a 100644 --- a/stats/src/components/sessions/SessionRow.tsx +++ b/stats/src/components/sessions/SessionRow.tsx @@ -120,7 +120,7 @@ export function SessionRow({ }} aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`} className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center" - title="View anime overview" + title="View in Library" > {'\u2197'} </button> diff --git a/stats/src/components/sessions/SessionsTab.test.tsx b/stats/src/components/sessions/SessionsTab.test.tsx new file mode 100644 index 00000000..ebf17334 --- /dev/null +++ b/stats/src/components/sessions/SessionsTab.test.tsx @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SessionBucket } from '../../lib/session-grouping'; +import type { SessionSummary } from '../../types/stats'; +import { buildBucketDeleteHandler } from './SessionsTab'; + +function makeSession(over: Partial<SessionSummary>): SessionSummary { + return { + sessionId: 1, + videoId: 100, + canonicalTitle: 'Episode 1', + startedAtMs: 1_000_000, + endedAtMs: 1_060_000, + activeWatchedMs: 60_000, + cardsMined: 1, + linesSeen: 10, + lookupCount: 5, + lookupHits: 3, + knownWordsSeen: 5, + ...over, + } as SessionSummary; +} + +function makeBucket(sessions: SessionSummary[]): SessionBucket { + const sorted = [...sessions].sort((a, b) => b.startedAtMs - a.startedAtMs); + return { + key: `v-${sorted[0]!.videoId}`, + videoId: sorted[0]!.videoId ?? null, + sessions: sorted, + totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0), + totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0), + representativeSession: sorted[0]!, + }; +} + +test('buildBucketDeleteHandler deletes every session in the bucket when confirm returns true', async () => { + let deleted: number[] | null = null; + let onSuccessCalledWith: number[] | null = null; + let onErrorCalled = false; + + const bucket = makeBucket([ + makeSession({ sessionId: 11, startedAtMs: 2_000_000 }), + makeSession({ sessionId: 22, startedAtMs: 3_000_000 }), + makeSession({ sessionId: 33, startedAtMs: 4_000_000 }), + ]); + + const handler = buildBucketDeleteHandler({ + bucket, + apiClient: { + deleteSessions: async (ids: number[]) => { + deleted = ids; + }, + }, + confirm: (title, count) => { + assert.equal(title, 'Episode 1'); + assert.equal(count, 3); + return true; + }, + onSuccess: (ids) => { + onSuccessCalledWith = ids; + }, + onError: () => { + onErrorCalled = true; + }, + }); + + await handler(); + + assert.deepEqual(deleted, [33, 22, 11]); + assert.deepEqual(onSuccessCalledWith, [33, 22, 11]); + assert.equal(onErrorCalled, false); +}); + +test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => { + let deleteCalled = false; + let successCalled = false; + + const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]); + + const handler = buildBucketDeleteHandler({ + bucket, + apiClient: { + deleteSessions: async () => { + deleteCalled = true; + }, + }, + confirm: () => false, + onSuccess: () => { + successCalled = true; + }, + onError: () => {}, + }); + + await handler(); + + assert.equal(deleteCalled, false); + assert.equal(successCalled, false); +}); + +test('buildBucketDeleteHandler reports errors via onError without calling onSuccess', async () => { + let errorMessage: string | null = null; + let successCalled = false; + + const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]); + + const handler = buildBucketDeleteHandler({ + bucket, + apiClient: { + deleteSessions: async () => { + throw new Error('boom'); + }, + }, + confirm: () => true, + onSuccess: () => { + successCalled = true; + }, + onError: (message) => { + errorMessage = message; + }, + }); + + await handler(); + + assert.equal(errorMessage, 'boom'); + assert.equal(successCalled, false); +}); + +test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => { + let seenTitle: string | null = null; + + const bucket = makeBucket([ + makeSession({ sessionId: 1, canonicalTitle: null }), + makeSession({ sessionId: 2, canonicalTitle: null }), + ]); + + const handler = buildBucketDeleteHandler({ + bucket, + apiClient: { deleteSessions: async () => {} }, + confirm: (title) => { + seenTitle = title; + return false; + }, + onSuccess: () => {}, + onError: () => {}, + }); + + await handler(); + + assert.equal(seenTitle, 'this episode'); +}); diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index 3975245b..c35e8c98 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -3,8 +3,9 @@ import { useSessions } from '../../hooks/useSessions'; import { SessionRow } from './SessionRow'; import { SessionDetail } from './SessionDetail'; import { apiClient } from '../../lib/api-client'; -import { confirmSessionDelete } from '../../lib/delete-confirm'; -import { formatSessionDayLabel } from '../../lib/formatters'; +import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm'; +import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters'; +import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping'; import type { SessionSummary } from '../../types/stats'; function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> { @@ -23,6 +24,35 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm return groups; } +export interface BucketDeleteDeps { + bucket: SessionBucket; + apiClient: { deleteSessions: (ids: number[]) => Promise<void> }; + confirm: (title: string, count: number) => boolean; + onSuccess: (deletedIds: number[]) => void; + onError: (message: string) => void; +} + +/** + * Build a handler that deletes every session in a bucket after confirmation. + * + * Extracted as a pure factory so the deletion flow can be unit-tested without + * rendering the full SessionsTab or mocking React state. + */ +export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> { + const { bucket, apiClient: client, confirm, onSuccess, onError } = deps; + return async () => { + const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; + const ids = bucket.sessions.map((s) => s.sessionId); + if (!confirm(title, ids.length)) return; + try { + await client.deleteSessions(ids); + onSuccess(ids); + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to delete sessions.'); + } + }; +} + interface SessionsTabProps { initialSessionId?: number | null; onClearInitialSession?: () => void; @@ -36,10 +66,12 @@ export function SessionsTab({ }: SessionsTabProps = {}) { const { sessions, loading, error } = useSessions(); const [expandedId, setExpandedId] = useState<number | null>(null); + const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set()); const [search, setSearch] = useState(''); const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]); const [deleteError, setDeleteError] = useState<string | null>(null); const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null); + const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null); useEffect(() => { setVisibleSessions(sessions); @@ -76,7 +108,16 @@ export function SessionsTab({ return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q)); }, [visibleSessions, search]); - const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]); + const dayGroups = useMemo(() => groupSessionsByDay(filtered), [filtered]); + + const toggleBucket = (key: string) => { + setExpandedBuckets((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; const handleDeleteSession = async (session: SessionSummary) => { if (!confirmSessionDelete()) return; @@ -94,6 +135,33 @@ export function SessionsTab({ } }; + const handleDeleteBucket = async (bucket: SessionBucket) => { + setDeleteError(null); + setDeletingBucketKey(bucket.key); + const handler = buildBucketDeleteHandler({ + bucket, + apiClient, + confirm: confirmBucketDelete, + onSuccess: (ids) => { + const deleted = new Set(ids); + setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId))); + setExpandedId((prev) => (prev != null && deleted.has(prev) ? null : prev)); + setExpandedBuckets((prev) => { + if (!prev.has(bucket.key)) return prev; + const next = new Set(prev); + next.delete(bucket.key); + return next; + }); + }, + onError: (message) => setDeleteError(message), + }); + try { + await handler(); + } finally { + setDeletingBucketKey(null); + } + }; + if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; @@ -110,39 +178,120 @@ export function SessionsTab({ {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null} - {Array.from(groups.entries()).map(([dayLabel, daySessions]) => ( - <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"> - {daySessions.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} /> + {Array.from(dayGroups.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> - )} - </div> - ); - })} + ); + } + + const bucketBodyId = `session-bucket-${bucket.key}`; + const isExpanded = expandedBuckets.has(bucket.key); + const title = bucket.representativeSession.canonicalTitle ?? 'Unknown Media'; + const deleteDisabled = deletingBucketKey === bucket.key; + return ( + <div key={bucket.key}> + <div className="relative group flex items-stretch gap-2"> + <button + type="button" + onClick={() => toggleBucket(bucket.key)} + aria-expanded={isExpanded} + aria-controls={bucketBodyId} + className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left" + > + <div + aria-hidden="true" + className={`text-ctp-overlay2 text-xs shrink-0 transition-transform ${ + isExpanded ? 'rotate-90' : '' + }`} + > + {'\u25B6'} + </div> + <div className="min-w-0 flex-1"> + <div className="text-sm font-medium text-ctp-text truncate">{title}</div> + <div className="text-xs text-ctp-overlay2"> + {bucket.sessions.length} session + {bucket.sessions.length === 1 ? '' : 's'} ·{' '} + {formatDuration(bucket.totalActiveMs)} active ·{' '} + {formatNumber(bucket.totalCardsMined)} cards + </div> + </div> + </button> + <button + type="button" + onClick={() => void handleDeleteBucket(bucket)} + disabled={deleteDisabled} + aria-label={`Delete all ${bucket.sessions.length} sessions of ${title}`} + title="Delete all sessions in this group" + className="shrink-0 w-8 rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-overlay2 hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed opacity-0 group-hover:opacity-100 focus:opacity-100" + > + {'\u2715'} + </button> + </div> + {isExpanded && ( + <div id={bucketBodyId} className="mt-2 ml-6 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> - </div> - ))} + ); + })} {filtered.length === 0 && ( <div className="text-ctp-overlay2 text-sm"> diff --git a/stats/src/components/trends/DateRangeSelector.tsx b/stats/src/components/trends/DateRangeSelector.tsx index 7d7352f3..c46c3979 100644 --- a/stats/src/components/trends/DateRangeSelector.tsx +++ b/stats/src/components/trends/DateRangeSelector.tsx @@ -53,7 +53,7 @@ export function DateRangeSelector({ <div className="flex items-center gap-4 text-sm"> <SegmentedControl label="Range" - options={['7d', '30d', '90d', 'all'] as TimeRange[]} + options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]} value={range} onChange={onRangeChange} formatLabel={(r) => (r === 'all' ? 'All' : r)} diff --git a/stats/src/components/trends/LibrarySummarySection.tsx b/stats/src/components/trends/LibrarySummarySection.tsx new file mode 100644 index 00000000..f3d06921 --- /dev/null +++ b/stats/src/components/trends/LibrarySummarySection.tsx @@ -0,0 +1,248 @@ +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<string>; +} + +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') { + 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<SortColumn>('watchTimeMin'); + const [sortDirection, setSortDirection] = useState<SortDirection>('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 ( + <div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4"> + <div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div> + </div> + ); + } + + const handleHeaderClick = (column: SortColumn) => { + if (column === sortColumn) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortColumn(column); + setSortDirection(column === 'title' ? 'asc' : 'desc'); + } + }; + + return ( + <> + <div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4"> + <h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3> + <ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}> + <BarChart + data={leaderboard} + layout="vertical" + margin={{ top: 8, right: 16, bottom: 8, left: 8 }} + > + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> + <XAxis + type="number" + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} + tickLine={false} + /> + <YAxis + type="category" + dataKey="displayTitle" + width={160} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} + tickLine={false} + interval={0} + /> + <Tooltip + contentStyle={TOOLTIP_CONTENT_STYLE} + formatter={(value: number) => [`${value} min`, 'Watch Time']} + labelFormatter={(_label, payload) => { + const datum = payload?.[0]?.payload as { title?: string } | undefined; + return datum?.title ?? ''; + }} + /> + <Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} /> + </BarChart> + </ResponsiveContainer> + </div> + <div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4"> + <h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3> + <div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}> + <table className="w-full text-xs"> + <thead className="sticky top-0 bg-ctp-surface0"> + <tr className="border-b border-ctp-surface1 text-ctp-subtext0"> + {COLUMNS.map((column) => { + const isActive = column.id === sortColumn; + const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : ''; + return ( + <th + key={column.id} + scope="col" + className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${ + column.align === 'right' ? 'text-right' : 'text-left' + } ${isActive ? 'text-ctp-text' : ''}`} + onClick={() => handleHeaderClick(column.id)} + > + {column.label} + {indicator} + </th> + ); + })} + </tr> + </thead> + <tbody> + {sortedRows.map((row) => ( + <tr + key={row.title} + className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40" + > + <td + className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate" + title={row.title} + > + {row.title} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {formatWatchTime(row.watchTimeMin)} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {formatNumber(row.videos)} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {formatNumber(row.sessions)} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {formatNumber(row.cards)} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {formatNumber(row.words)} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {formatNumber(row.lookups)} + </td> + <td className="px-2 py-2 text-right text-ctp-text tabular-nums"> + {row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)} + </td> + <td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums"> + {formatDateRange(row.firstWatched, row.lastWatched)} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + </> + ); +} diff --git a/stats/src/components/trends/StackedTrendChart.tsx b/stats/src/components/trends/StackedTrendChart.tsx index c56a8bce..c196728d 100644 --- a/stats/src/components/trends/StackedTrendChart.tsx +++ b/stats/src/components/trends/StackedTrendChart.tsx @@ -1,4 +1,13 @@ -import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; +import { + AreaChart, + Area, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; import { epochDayToDate } from '../../lib/formatters'; export interface PerAnimeDataPoint { @@ -64,14 +73,6 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha const { points, seriesKeys } = buildLineData(data); const colors = colorPalette ?? DEFAULT_LINE_COLORS; - const tooltipStyle = { - background: '#363a4f', - border: '1px solid #494d64', - borderRadius: 6, - color: '#cad3f5', - fontSize: 12, - }; - if (points.length === 0) { return ( <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> @@ -84,21 +85,22 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha 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={120}> - <AreaChart data={points}> + <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}> + <AreaChart data={points} margin={CHART_DEFAULTS.margin}> + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> <XAxis dataKey="label" - tick={{ fontSize: 9, fill: '#a5adcb' }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} /> <YAxis - tick={{ fontSize: 9, fill: '#a5adcb' }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} - width={28} + width={32} /> - <Tooltip contentStyle={tooltipStyle} /> + <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} /> {seriesKeys.map((key, i) => ( <Area key={key} diff --git a/stats/src/components/trends/TrendChart.tsx b/stats/src/components/trends/TrendChart.tsx index f595f786..e4b1a742 100644 --- a/stats/src/components/trends/TrendChart.tsx +++ b/stats/src/components/trends/TrendChart.tsx @@ -6,8 +6,10 @@ import { XAxis, YAxis, Tooltip, + CartesianGrid, ResponsiveContainer, } from 'recharts'; +import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; interface TrendChartProps { title: string; @@ -19,35 +21,29 @@ interface TrendChartProps { } export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) { - const tooltipStyle = { - background: '#363a4f', - border: '1px solid #494d64', - borderRadius: 6, - color: '#cad3f5', - fontSize: 12, - }; - 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={120}> + <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}> {type === 'bar' ? ( - <BarChart data={data}> + <BarChart data={data} margin={CHART_DEFAULTS.margin}> + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> <XAxis dataKey="label" - tick={{ fontSize: 9, fill: '#a5adcb' }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} /> <YAxis - tick={{ fontSize: 9, fill: '#a5adcb' }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} - width={28} + width={32} + tickFormatter={formatter} /> - <Tooltip contentStyle={tooltipStyle} formatter={formatValue} /> + <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> <Bar dataKey="value" fill={color} @@ -59,20 +55,22 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }: /> </BarChart> ) : ( - <LineChart data={data}> + <LineChart data={data} margin={CHART_DEFAULTS.margin}> + <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> <XAxis dataKey="label" - tick={{ fontSize: 9, fill: '#a5adcb' }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} /> <YAxis - tick={{ fontSize: 9, fill: '#a5adcb' }} - axisLine={false} + tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} + axisLine={{ stroke: CHART_THEME.axisLine }} tickLine={false} - width={28} + width={32} + tickFormatter={formatter} /> - <Tooltip contentStyle={tooltipStyle} formatter={formatValue} /> + <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> <Line dataKey="value" stroke={color} strokeWidth={2} dot={false} /> </LineChart> )} diff --git a/stats/src/components/trends/TrendsTab.test.tsx b/stats/src/components/trends/TrendsTab.test.tsx new file mode 100644 index 00000000..708ad909 --- /dev/null +++ b/stats/src/components/trends/TrendsTab.test.tsx @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { AnimeVisibilityFilter } from './TrendsTab'; + +test('AnimeVisibilityFilter uses title visibility wording', () => { + const markup = renderToStaticMarkup( + <AnimeVisibilityFilter + animeTitles={['KonoSuba']} + hiddenAnime={new Set()} + onShowAll={() => {}} + onHideAll={() => {}} + onToggleAnime={() => {}} + />, + ); + + assert.match(markup, /Title Visibility/); + assert.doesNotMatch(markup, /Anime Visibility/); +}); diff --git a/stats/src/components/trends/TrendsTab.tsx b/stats/src/components/trends/TrendsTab.tsx index 0010bd57..abfbf884 100644 --- a/stats/src/components/trends/TrendsTab.tsx +++ b/stats/src/components/trends/TrendsTab.tsx @@ -8,6 +8,7 @@ import { filterHiddenAnimeData, pruneHiddenAnime, } from './anime-visibility'; +import { LibrarySummarySection } from './LibrarySummarySection'; function SectionHeader({ children }: { children: React.ReactNode }) { return ( @@ -28,7 +29,7 @@ interface AnimeVisibilityFilterProps { onToggleAnime: (title: string) => void; } -function AnimeVisibilityFilter({ +export function AnimeVisibilityFilter({ animeTitles, hiddenAnime, onShowAll, @@ -44,7 +45,7 @@ function AnimeVisibilityFilter({ <div className="mb-2 flex items-center justify-between gap-3"> <div> <h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0"> - Anime Visibility + Title Visibility </h4> <p className="mt-1 text-xs text-ctp-overlay1"> Shared across all anime trend charts. Default: show everything. @@ -114,11 +115,6 @@ export function TrendsTab() { if (!data) return null; const animeTitles = buildAnimeVisibilityOptions([ - data.animePerDay.episodes, - data.animePerDay.watchTime, - data.animePerDay.cards, - data.animePerDay.words, - data.animePerDay.lookups, data.animeCumulative.episodes, data.animeCumulative.cards, data.animeCumulative.words, @@ -126,24 +122,6 @@ export function TrendsTab() { ]); const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles); - 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, - ); const filteredAnimeProgress = filterHiddenAnimeData( data.animeCumulative.episodes, activeHiddenAnime, @@ -185,6 +163,18 @@ export function TrendsTab() { /> <TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" /> <TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" /> + <TrendChart + title="Watch Time by Day of Week (min)" + data={data.patterns.watchTimeByDayOfWeek} + color="#8aadf4" + type="bar" + /> + <TrendChart + title="Watch Time by Hour (min)" + data={data.patterns.watchTimeByHour} + color="#c6a0f6" + type="bar" + /> <SectionHeader>Period Trends</SectionHeader> <TrendChart @@ -221,7 +211,7 @@ export function TrendsTab() { type="line" /> - <SectionHeader>Anime — Per Day</SectionHeader> + <SectionHeader>Library — Cumulative</SectionHeader> <AnimeVisibilityFilter animeTitles={animeTitles} hiddenAnime={activeHiddenAnime} @@ -239,21 +229,6 @@ export function TrendsTab() { }) } /> - <StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} /> - <StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} /> - <StackedTrendChart - title="Cards Mined per Anime" - data={filteredCardsPerAnime} - colorPalette={cardsMinedStackedColors} - /> - <StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} /> - <StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} /> - <StackedTrendChart - title="Lookups/100w per Anime" - data={filteredLookupsPerHundredPerAnime} - /> - - <SectionHeader>Anime — Cumulative</SectionHeader> <StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} /> <StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} /> <StackedTrendChart @@ -263,19 +238,8 @@ export function TrendsTab() { /> <StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} /> - <SectionHeader>Patterns</SectionHeader> - <TrendChart - title="Watch Time by Day of Week (min)" - data={data.patterns.watchTimeByDayOfWeek} - color="#8aadf4" - type="bar" - /> - <TrendChart - title="Watch Time by Hour (min)" - data={data.patterns.watchTimeByHour} - color="#c6a0f6" - type="bar" - /> + <SectionHeader>Library — Summary</SectionHeader> + <LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} /> </div> </div> ); diff --git a/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx b/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx index 15b1d5e4..f6e997af 100644 --- a/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx +++ b/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx @@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({ > {'\u25B6'} </span> - Words In Multiple Anime + Words Across Multiple Titles </button> <div className="flex items-center gap-3"> {hasKnownData && ( @@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({ {collapsed ? null : ranked.length === 0 ? ( <div className="text-xs text-ctp-overlay2 mt-3"> {hideKnown - ? 'All multi-anime words are already known!' - : 'No words found across multiple anime.'} + ? 'All words that span multiple titles are already known!' + : 'No words found across multiple titles.'} </div> ) : ( <> @@ -109,7 +109,7 @@ export function CrossAnimeWordsTable({ <th className="text-left py-2 pr-3 font-medium">Word</th> <th className="text-left py-2 pr-3 font-medium">Reading</th> <th className="text-left py-2 pr-3 font-medium w-20">POS</th> - <th className="text-right py-2 pr-3 font-medium w-16">Anime</th> + <th className="text-right py-2 pr-3 font-medium w-16">Titles</th> <th className="text-right py-2 font-medium w-16">Seen</th> </tr> </thead> diff --git a/stats/src/components/vocabulary/FrequencyRankTable.test.tsx b/stats/src/components/vocabulary/FrequencyRankTable.test.tsx new file mode 100644 index 00000000..37f64d2e --- /dev/null +++ b/stats/src/components/vocabulary/FrequencyRankTable.test.tsx @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +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; +} + +test('renders headword and reading inline in a single column (no separate Reading header)', () => { + const entry = makeEntry({}); + const markup = renderToStaticMarkup( + <FrequencyRankTable words={[entry]} knownWords={new Set()} />, + ); + assert.ok(!markup.includes('>Reading<'), 'should not have a Reading column header'); + assert.ok(markup.includes('日本語'), 'should include the headword'); + assert.ok(markup.includes('にほんご'), 'should include the reading inline'); +}); + +test('omits reading when reading equals headword', () => { + const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' }); + const markup = renderToStaticMarkup( + <FrequencyRankTable words={[entry]} knownWords={new Set()} />, + ); + assert.ok(markup.includes('カレー'), 'should include the headword'); + assert.ok( + !markup.includes('【'), + 'should not render any bracketed reading when equal to headword', + ); +}); diff --git a/stats/src/components/vocabulary/FrequencyRankTable.tsx b/stats/src/components/vocabulary/FrequencyRankTable.tsx index a7fec63c..470a60c9 100644 --- a/stats/src/components/vocabulary/FrequencyRankTable.tsx +++ b/stats/src/components/vocabulary/FrequencyRankTable.tsx @@ -113,7 +113,6 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc <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">Reading</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> @@ -128,9 +127,19 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc <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 text-ctp-text font-medium">{w.headword}</td> - <td className="py-1.5 pr-3 text-ctp-subtext0"> - {fullReading(w.headword, w.reading) || w.headword} + <td className="py-1.5 pr-3"> + <span className="text-ctp-text font-medium">{w.headword}</span> + {(() => { + const reading = fullReading(w.headword, w.reading); + // `fullReading` normalizes katakana to hiragana, so we normalize the + // headword the same way before comparing — otherwise katakana-only + // entries like `カレー` would render `【かれー】`. + const normalizedHeadword = fullReading(w.headword, w.headword); + if (!reading || reading === normalizedHeadword) 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} />} diff --git a/stats/src/hooks/useMediaLibrary.test.ts b/stats/src/hooks/useMediaLibrary.test.ts deleted file mode 100644 index 39abbbea..00000000 --- a/stats/src/hooks/useMediaLibrary.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { MediaLibraryItem } from '../types/stats'; -import { shouldRefreshMediaLibraryRows } from './useMediaLibrary'; - -const baseItem: MediaLibraryItem = { - videoId: 1, - canonicalTitle: 'watch?v=abc123', - totalSessions: 1, - totalActiveMs: 60_000, - totalCards: 0, - totalTokensSeen: 10, - lastWatchedMs: 1_000, - hasCoverArt: 0, - youtubeVideoId: 'abc123', - videoUrl: 'https://www.youtube.com/watch?v=abc123', - videoTitle: null, - videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg', - channelId: null, - channelName: null, - channelUrl: null, - channelThumbnailUrl: null, - uploaderId: null, - uploaderUrl: null, - description: null, -}; - -test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => { - assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true); -}); - -test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => { - assert.equal( - shouldRefreshMediaLibraryRows([ - { - ...baseItem, - videoTitle: 'Video Name', - channelName: 'Creator Name', - channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88', - }, - ]), - false, - ); -}); - -test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => { - assert.equal( - shouldRefreshMediaLibraryRows([ - { - ...baseItem, - youtubeVideoId: null, - videoUrl: null, - }, - ]), - false, - ); -}); diff --git a/stats/src/hooks/useMediaLibrary.ts b/stats/src/hooks/useMediaLibrary.ts deleted file mode 100644 index a28b0c59..00000000 --- a/stats/src/hooks/useMediaLibrary.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useEffect } from 'react'; -import { getStatsClient } from './useStatsApi'; -import type { MediaLibraryItem } from '../types/stats'; - -const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500; -const MEDIA_LIBRARY_MAX_RETRIES = 3; - -export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean { - return rows.some((row) => { - if (!row.youtubeVideoId) { - return false; - } - return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim(); - }); -} - -export function useMediaLibrary() { - const [media, setMedia] = useState<MediaLibraryItem[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - 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); - } - }; - }, []); - - return { media, loading, error }; -} diff --git a/stats/src/hooks/useTrends.ts b/stats/src/hooks/useTrends.ts index 4f65a01c..907a2f28 100644 --- a/stats/src/hooks/useTrends.ts +++ b/stats/src/hooks/useTrends.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { getStatsClient } from './useStatsApi'; import type { TrendsDashboardData } from '../types/stats'; -export type TimeRange = '7d' | '30d' | '90d' | 'all'; +export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all'; export type GroupBy = 'day' | 'month'; export function useTrends(range: TimeRange, groupBy: GroupBy) { diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts index 85c794ee..88f9989d 100644 --- a/stats/src/lib/api-client.test.ts +++ b/stats/src/lib/api-client.test.ts @@ -84,14 +84,7 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and lookups: [], }, ratios: { lookupsPerHundred: [] }, - animePerDay: { - episodes: [], - watchTime: [], - cards: [], - words: [], - lookups: [], - lookupsPerHundred: [], - }, + librarySummary: [], animeCumulative: { watchTime: [], episodes: [], @@ -115,6 +108,48 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and } }); +test('getTrendsDashboard accepts 365d range and builds correct URL', async () => { + const originalFetch = globalThis.fetch; + let seenUrl = ''; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seenUrl = String(input); + return new Response( + JSON.stringify({ + activity: { watchTime: [], cards: [], words: [], sessions: [] }, + progress: { + watchTime: [], + sessions: [], + words: [], + newWords: [], + cards: [], + episodes: [], + lookups: [], + }, + ratios: { lookupsPerHundred: [] }, + librarySummary: [], + animeCumulative: { + watchTime: [], + episodes: [], + cards: [], + words: [], + }, + patterns: { + watchTimeByDayOfWeek: [], + watchTimeByHour: [], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof globalThis.fetch; + + try { + await apiClient.getTrendsDashboard('365d', 'day'); + assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('getSessionEvents can request only specific event types', async () => { const originalFetch = globalThis.fetch; let seenUrl = ''; diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index e4576e21..083d05f4 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -116,7 +116,7 @@ export const apiClient = { fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`), getWatchTimePerAnime: (limit = 90) => fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), - getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') => + getTrendsDashboard: (range: '7d' | '30d' | '90d' | '365d' | 'all', groupBy: 'day' | 'month') => fetchJson<TrendsDashboardData>( `/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`, ), diff --git a/stats/src/lib/chart-theme.test.ts b/stats/src/lib/chart-theme.test.ts new file mode 100644 index 00000000..1288914d --- /dev/null +++ b/stats/src/lib/chart-theme.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from './chart-theme'; + +test('CHART_THEME exposes a grid color', () => { + assert.equal(CHART_THEME.grid, '#494d64'); +}); + +test('CHART_DEFAULTS uses 11px ticks for legibility', () => { + assert.equal(CHART_DEFAULTS.tickFontSize, 11); +}); + +test('TOOLTIP_CONTENT_STYLE mirrors the shared tooltip colors', () => { + assert.equal(TOOLTIP_CONTENT_STYLE.background, CHART_THEME.tooltipBg); + assert.ok(String(TOOLTIP_CONTENT_STYLE.border).includes(CHART_THEME.tooltipBorder)); +}); diff --git a/stats/src/lib/chart-theme.ts b/stats/src/lib/chart-theme.ts index 549b0157..ffb62173 100644 --- a/stats/src/lib/chart-theme.ts +++ b/stats/src/lib/chart-theme.ts @@ -5,4 +5,21 @@ export const CHART_THEME = { 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, +}; diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 35889daf..585d19db 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + confirmBucketDelete, confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete, @@ -54,6 +55,42 @@ test('confirmDayGroupDelete uses singular for one session', () => { } }); +test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + globalThis.confirm = ((message?: string) => { + calls.push(message ?? ''); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(confirmBucketDelete('My Episode', 3), true); + assert.deepEqual(calls, [ + 'Delete all 3 sessions of "My Episode" from this day and all associated data?', + ]); + } finally { + globalThis.confirm = originalConfirm; + } +}); + +test('confirmBucketDelete uses a clean singular form for one session', () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + globalThis.confirm = ((message?: string) => { + calls.push(message ?? ''); + return false; + }) as typeof globalThis.confirm; + + try { + assert.equal(confirmBucketDelete('Solo Episode', 1), false); + assert.deepEqual(calls, [ + 'Delete this session of "Solo Episode" from this day and all associated data?', + ]); + } finally { + globalThis.confirm = originalConfirm; + } +}); + test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index b3f7cd31..137e3996 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -17,3 +17,14 @@ export function confirmAnimeGroupDelete(title: string, count: number): boolean { export function confirmEpisodeDelete(title: string): boolean { return globalThis.confirm(`Delete "${title}" and all its sessions?`); } + +export function confirmBucketDelete(title: string, count: number): boolean { + if (count === 1) { + return globalThis.confirm( + `Delete this session of "${title}" from this day and all associated data?`, + ); + } + return globalThis.confirm( + `Delete all ${count} sessions of "${title}" from this day and all associated data?`, + ); +} diff --git a/stats/src/lib/session-detail.test.tsx b/stats/src/lib/session-detail.test.tsx index e5d63aab..c8e3be80 100644 --- a/stats/src/lib/session-detail.test.tsx +++ b/stats/src/lib/session-detail.test.tsx @@ -46,9 +46,10 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r { eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' }, ]); + // Seek events are intentionally dropped from the chart — they were too noisy. assert.deepEqual( - chartEvents.seekEvents.map((event) => event.eventType), - [EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD], + chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'), + [], ); assert.deepEqual( chartEvents.cardEvents.map((event) => event.tsMs), diff --git a/stats/src/lib/session-events.test.ts b/stats/src/lib/session-events.test.ts index cdfd990c..a8a3fcbc 100644 --- a/stats/src/lib/session-events.test.ts +++ b/stats/src/lib/session-events.test.ts @@ -29,25 +29,20 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m { eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null }, ]); + // Seek events are intentionally dropped — too noisy on the session chart. assert.deepEqual( chartEvents.markers.map((marker) => marker.kind), - ['seek', 'pause', 'card'], + ['pause', 'card'], ); - const seekMarker = chartEvents.markers[0]!; - assert.equal(seekMarker.kind, 'seek'); - assert.equal(seekMarker.direction, 'forward'); - assert.equal(seekMarker.fromMs, 1_000); - assert.equal(seekMarker.toMs, 5_500); - - const pauseMarker = chartEvents.markers[1]!; + const pauseMarker = chartEvents.markers[0]!; assert.equal(pauseMarker.kind, 'pause'); assert.equal(pauseMarker.startMs, 2_000); assert.equal(pauseMarker.endMs, 5_000); assert.equal(pauseMarker.durationMs, 3_000); assert.equal(pauseMarker.anchorTsMs, 3_500); - const cardMarker = chartEvents.markers[2]!; + const cardMarker = chartEvents.markers[1]!; assert.equal(cardMarker.kind, 'card'); assert.deepEqual(cardMarker.noteIds, [11, 22]); assert.equal(cardMarker.cardsDelta, 2); diff --git a/stats/src/lib/session-events.ts b/stats/src/lib/session-events.ts index ddacfcdb..1ef473f9 100644 --- a/stats/src/lib/session-events.ts +++ b/stats/src/lib/session-events.ts @@ -2,8 +2,6 @@ import { EventType, type SessionEvent } from '../types/stats'; export const SESSION_CHART_EVENT_TYPES = [ EventType.CARD_MINED, - EventType.SEEK_FORWARD, - EventType.SEEK_BACKWARD, EventType.PAUSE_START, EventType.PAUSE_END, EventType.YOMITAN_LOOKUP, @@ -16,7 +14,6 @@ export interface PauseRegion { export interface SessionChartEvents { cardEvents: SessionEvent[]; - seekEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[]; pauseRegions: PauseRegion[]; markers: SessionChartMarker[]; @@ -58,15 +55,6 @@ export type SessionChartMarker = endMs: number; durationMs: number; } - | { - key: string; - kind: 'seek'; - anchorTsMs: number; - eventTsMs: number; - direction: 'forward' | 'backward'; - fromMs: number | null; - toMs: number | null; - } | { key: string; kind: 'card'; @@ -295,7 +283,6 @@ export function projectSessionMarkerLeftPx({ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents { const cardEvents: SessionEvent[] = []; - const seekEvents: SessionEvent[] = []; const yomitanLookupEvents: SessionEvent[] = []; const pauseRegions: PauseRegion[] = []; const markers: SessionChartMarker[] = []; @@ -317,22 +304,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve }); } break; - case EventType.SEEK_FORWARD: - case EventType.SEEK_BACKWARD: - seekEvents.push(event); - { - const payload = parsePayload(event.payload); - markers.push({ - key: `seek-${event.tsMs}-${event.eventType}`, - kind: 'seek', - anchorTsMs: event.tsMs, - eventTsMs: event.tsMs, - direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward', - fromMs: readNumberField(payload?.fromMs), - toMs: readNumberField(payload?.toMs), - }); - } - break; case EventType.YOMITAN_LOOKUP: yomitanLookupEvents.push(event); break; @@ -376,7 +347,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve return { cardEvents, - seekEvents, yomitanLookupEvents, pauseRegions, markers, diff --git a/stats/src/lib/session-grouping.test.ts b/stats/src/lib/session-grouping.test.ts new file mode 100644 index 00000000..3215a447 --- /dev/null +++ b/stats/src/lib/session-grouping.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SessionSummary } from '../types/stats'; +import { groupSessionsByVideo } from './session-grouping'; + +function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary { + return { + sessionId: overrides.sessionId, + canonicalTitle: null, + videoId: null, + animeId: null, + animeTitle: null, + startedAtMs: 1000, + endedAtMs: null, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + yomitanLookupCount: 0, + knownWordsSeen: 0, + knownWordRate: 0, + ...overrides, + }; +} + +test('empty input returns empty array', () => { + assert.deepEqual(groupSessionsByVideo([]), []); +}); + +test('two unique videoIds produce 2 singleton buckets', () => { + const sessions = [ + makeSession({ + sessionId: 1, + videoId: 10, + startedAtMs: 1000, + activeWatchedMs: 100, + cardsMined: 2, + }), + makeSession({ + sessionId: 2, + videoId: 20, + startedAtMs: 2000, + activeWatchedMs: 200, + cardsMined: 3, + }), + ]; + const buckets = groupSessionsByVideo(sessions); + assert.equal(buckets.length, 2); + const keys = buckets.map((b) => b.key).sort(); + assert.deepEqual(keys, ['v-10', 'v-20']); + for (const bucket of buckets) { + assert.equal(bucket.sessions.length, 1); + } +}); + +test('two sessions sharing a videoId collapse into 1 bucket with summed totals and most-recent representative', () => { + const older = makeSession({ + sessionId: 1, + videoId: 42, + startedAtMs: 1000, + activeWatchedMs: 300, + cardsMined: 5, + }); + const newer = makeSession({ + sessionId: 2, + videoId: 42, + startedAtMs: 9000, + activeWatchedMs: 500, + cardsMined: 7, + }); + const buckets = groupSessionsByVideo([older, newer]); + assert.equal(buckets.length, 1); + const [bucket] = buckets; + assert.equal(bucket!.key, 'v-42'); + assert.equal(bucket!.videoId, 42); + assert.equal(bucket!.sessions.length, 2); + assert.equal(bucket!.totalActiveMs, 800); + assert.equal(bucket!.totalCardsMined, 12); + assert.equal(bucket!.representativeSession.sessionId, 2); // most recent (highest startedAtMs) +}); + +test('sessions with null videoId become singleton buckets keyed by sessionId', () => { + const s1 = makeSession({ sessionId: 101, videoId: null, activeWatchedMs: 50, cardsMined: 1 }); + const s2 = makeSession({ sessionId: 202, videoId: null, activeWatchedMs: 75, cardsMined: 2 }); + const buckets = groupSessionsByVideo([s1, s2]); + assert.equal(buckets.length, 2); + const keys = buckets.map((b) => b.key).sort(); + assert.deepEqual(keys, ['s-101', 's-202']); + for (const bucket of buckets) { + assert.equal(bucket.videoId, null); + assert.equal(bucket.sessions.length, 1); + } +}); diff --git a/stats/src/lib/session-grouping.ts b/stats/src/lib/session-grouping.ts new file mode 100644 index 00000000..01e9e423 --- /dev/null +++ b/stats/src/lib/session-grouping.ts @@ -0,0 +1,43 @@ +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 byKey = 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 = byKey.get(key); + if (existing) existing.push(session); + else byKey.set(key, [session]); + } + + const buckets: SessionBucket[] = []; + for (const [key, group] of byKey) { + 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((s, x) => s + x.activeWatchedMs, 0), + totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0), + representativeSession: representative, + }); + } + + return buckets; +} diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts index 29e05f6b..81861fee 100644 --- a/stats/src/types/stats.ts +++ b/stats/src/types/stats.ts @@ -288,6 +288,19 @@ export interface TrendPerAnimePoint { value: number; } +export interface LibrarySummaryRow { + title: string; + watchTimeMin: number; + videos: number; + sessions: number; + cards: number; + words: number; + lookups: number; + lookupsPerHundred: number | null; + firstWatched: number; + lastWatched: number; +} + export interface TrendsDashboardData { activity: { watchTime: TrendChartPoint[]; @@ -307,14 +320,7 @@ export interface TrendsDashboardData { ratios: { lookupsPerHundred: TrendChartPoint[]; }; - animePerDay: { - episodes: TrendPerAnimePoint[]; - watchTime: TrendPerAnimePoint[]; - cards: TrendPerAnimePoint[]; - words: TrendPerAnimePoint[]; - lookups: TrendPerAnimePoint[]; - lookupsPerHundred: TrendPerAnimePoint[]; - }; + librarySummary: LibrarySummaryRow[]; animeCumulative: { watchTime: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[];