11 KiB
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 inc5e778d7; 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:
- A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
- 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:
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[]toTrendsDashboardQueryResult. - Populate it from a single aggregating query over
imm_daily_rollupsjoined toimm_videos→imm_anime, filtered byrollup_daywithin the selected window. Session count and lookup count come fromimm_sessionsaggregated byvideo_idand then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1. imm_animeis the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name staysimm_anime; only the new field uses generic naming.- Return rows pre-sorted by
watchTimeMindescending so the leaderboard is zero-cost and the table default sort matches. - Emit
lookupsPerHundred: nullwhenwords == 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"withYAxis dataKey="title" type="category"andXAxis type="number". - Single series color:
#8aadf4(matching the existing Watch Time color). - Reuse
CHART_DEFAULTS,CHART_THEME,TOOLTIP_CONTENT_STYLEfromstats/src/lib/chart-theme.tsso 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:
- Title — left-aligned, sticky, truncated with ellipsis, full title on hover.
- Watch Time — formatted
Xh Ymwhen ≥60 min, elseXm. - Videos — integer.
- Sessions — integer.
- Cards — integer.
- Words — integer.
- Lookups — integer.
- Lookups/100w — one decimal place,
—when null. - Date Range —
Mon D → Mon Dusing the title'sfirstWatched/lastWatchedwithin the window.
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
- Numeric columns right-aligned.
- Null
lookupsPerHundredsorts 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,filteredLookupsPerHundredPerAnimelocals. - 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
buildAnimeVisibilityOptionsinput to uselibrarySummarytitles instead of the six droppedanimePerDay.*arrays.
Data flow
useTrends(range, groupBy)calls/api/stats/trends/dashboard.- Response now includes
librarySummary(sorted by watch time desc). TrendsTabholds the sharedhiddenAnimeset (unchanged).LibrarySummarySectionreceiveslibrarySummary+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.- Date-range selector changes trigger a new fetch;
groupBytoggle 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:
lookupsPerHundredisnull, 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
titleattribute / ECharts tooltip. - Mixed sources (anime + YouTube): No special case — both land in
imm_animeand are grouped uniformly.
Testing
Backend (query-trends.ts)
New unit tests, following the existing pattern:
- Empty window returns
librarySummary: []. - Single title with a few rollups: all aggregates are correct;
firstWatched/lastWatchedmatch the bounding days within the window. - Multiple titles: rows returned sorted by watch time desc.
- Mixed sources (anime-style + YouTube-style entries in
imm_anime): both appear in the summary with their own aggregates. - Title with
words == 0:lookupsPerHundredisnull. - Date range excludes some rollups: excluded rollups are not counted;
firstWatched/lastWatchedreflect only within-window activity. sessionsandlookupscome fromimm_sessions, notimm_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/*.mdsummarizing the replacement. - No user-facing docs (
docs-site/) changes unless the per-day section was documented there — verify during implementation.
Open items
None.