44 KiB
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— addLibrarySummaryRowtype,buildLibrarySummaryhelper, wire intogetTrendsDashboard, dropanimePerDayfromTrendsDashboardQueryResult, delete now-unusedbuildPerAnimeFromSessionsandbuildLookupsPerHundredPerAnime.__tests__/query.test.ts— update existinggetTrendsDashboardtest (dropanimePerDayassertion, addlibrarySummaryassertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred).
Backend test fixtures:
src/core/services/__tests__/stats-server.test.ts— updateTRENDS_DASHBOARDfixture (removeanimePerDay, addlibrarySummary), fixassert.deepEqualthat referencesbody.animePerDay.watchTime.
Frontend (stats/src/):
types/stats.ts— addLibrarySummaryRowinterface, addlibrarySummaryfield toTrendsDashboardData, removeanimePerDayfield.lib/api-client.test.ts— update the two inline fetch-mock fixtures (removeanimePerDay, addlibrarySummary).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), addLibrarySummarySectionimport + usage, updatebuildAnimeVisibilityOptionscall to uselibrarySummarytitles instead of the six droppedanimePerDay.*arrays.components/trends/anime-visibility.ts— unchanged. The existing helpers operate onPerAnimeDataPoint[]; we'll adapt by passing a derivedPerAnimeDataPoint[]built fromlibrarySummary(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:
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:
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
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).
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
buildLibrarySummaryhelper
Open src/core/services/immersion-tracker/query-trends.ts. Add this helper function near the other builders (e.g., after buildCumulativePerAnime, before getVideoAnimeTitleMap):
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;
}
- Step 4: Wire
buildLibrarySummaryintogetTrendsDashboard
Still in query-trends.ts, inside getTrendsDashboard, replace the stub librarySummary: [] line with:
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
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: nullwhen words == 0
Append to src/core/services/immersion-tracker/__tests__/query.test.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
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
animePerDayfromTrendsDashboardQueryResult
In src/core/services/immersion-tracker/query-trends.ts, delete the animePerDay block from the interface (lines ~64-71):
// Delete this block:
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
- Step 2: Scope the intermediate
animePerDayto 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:
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
getTrendsDashboardtest assertion that referencesanimePerDay
In src/core/services/immersion-tracker/__tests__/query.test.ts, find the test getTrendsDashboard returns chart-ready aggregated series. Replace the line:
assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
with:
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):
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:
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
Replace it with:
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
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
LibrarySummaryRowand updateTrendsDashboardDatainstats/src/types/stats.ts
Add above TrendsDashboardData (around line 291):
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):
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:
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
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:
import type { LibrarySummaryRow } from '../../types/stats';
interface LibrarySummarySectionProps {
rows: LibrarySummaryRow[];
hiddenTitles: ReadonlySet<string>;
}
export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) {
const visibleRows = rows.filter((row) => !hiddenTitles.has(row.title));
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>
);
}
return (
<>
{/* Leaderboard + table cards added in Tasks 7 and 8 */}
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<div className="text-xs text-ctp-overlay2">
Library summary: {visibleRows.length} titles
</div>
</div>
</>
);
}
- 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
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:
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<string>;
}
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 (
<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 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 (
<>
<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>
{/* 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
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 <table> card after the leaderboard card.
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') {
// 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<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>
</>
);
}
- 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
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):
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
buildAnimeVisibilityOptionsto uselibrarySummarytitles
Replace the existing const animeTitles = buildAnimeVisibilityOptions([...]) block (currently lines 116-126) with:
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
LibrarySummarySectionat the top of the file
Add to the imports at the top (near the other ./ imports on line 5):
import { LibrarySummarySection } from './LibrarySummarySection';
- Step 4: Replace the "Library — Per Day" JSX block
Find lines 224-254 (the block starting with <SectionHeader>Library — Per Day</SectionHeader>). Replace the entire block through the final /> of Lookups/100w per Title with:
<SectionHeader>Library — Summary</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
onShowAll={() => 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;
})
}
/>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
(The AnimeVisibilityFilter moves from the per-day section into the summary section — same component, same props pattern.)
- Step 5: Verify
StackedTrendChartandfilterHiddenAnimeDataare 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
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):
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
bun run build
bun run test:smoke:dist
Expected: PASS.
- Step 5: Commit the fragment
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 === 0show—in the Lookups/100w column. - Empty window shows "No library activity in the selected window."
- The "Library — Cumulative" section below is unchanged.
git log --onelineshows small, focused commits per task.