Design for a single PR covering seven stats dashboard items: collapsible library series groups, same-episode session rollups, 365d trend range, episode delete in library detail, tighter vocabulary word/reading column, filtering Anki-deleted cards, and a chart clarity pass with shared theming.
16 KiB
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:
- Library — collapse episodes behind a per-series dropdown.
- Sessions — roll up multiple sessions of the same episode within a day.
- Trends — add a 365d range option.
- Library — delete an episode (video) from its detail view.
- Vocabulary — tighten spacing between word and reading in the Top 50 table.
- Episode detail — hide cards whose Anki notes have been deleted.
- 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— extendTrendRangeandTREND_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.
feat(stats): add 365d range to trends dashboard(item 3)fix(stats): tighten word/reading column in Top 50 table(item 5)fix(stats): hide cards deleted from Anki in episode detail(item 6)feat(stats): delete episode from library detail view(item 4)feat(stats): collapsible series groups in library(item 1)feat(stats): roll up same-episode sessions within a day(item 2)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 MediaCards 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 fromgroupedwhereitems.length > 1. - Toggle helper:
toggleGroup(key: string)adds/removes from the set. - Group header: wrap in a
<button>witharia-expandedand 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: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
videoIdbecome singleton buckets. -
SessionsTab.tsx: after day grouping, pipe eachdaySessionsthroughgroupSessionsByVideo. Render each bucket:sessions.length === 1: existingSessionRowbehavior, unchanged.sessions.length >= 2: render a bucket row that looks likeSessionRowbut shows combined totals and session count (e.g.3 sessions · 1h 24m · 12 cards). Chevron state stored in a secondSet<string>on bucket key. Expanded buckets render the childSessionRows indented (pl-8) beneath the header.
-
MediaSessionList.tsx: within the media detail view, a single video's sessions are all the samevideoIdby 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 sharedSessionBucketRowcomponent.
Delete semantics
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse
confirmDayGroupDeletepattern with a bucket-specific message, or addconfirmBucketDelete). - 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': 365toTREND_DAY_LIMITS. getTrendDayLimitpicks up the new key automatically because of theExclude<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 theTimeRangeunion.components/trends/DateRangeSelector.tsx: add'365d'to the options list. Display label stays as365d.lib/api-client.ts/api-client.test.ts: if the client validates ranges, add365d.
Tests
query.test.ts: extend the existing range table to cover365dreturning 365 days of data.stats-server.test.ts: ensure the route acceptsrange=365d.api-client.test.ts: ensure the client emits the new range.
Change-specific checks
bun run test:configis 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?: () => voidprop toMediaHeaderand render the button only if provided. - In
MediaDetailView:- New handler
handleDeleteEpisodethat callsapiClient.deleteVideo(videoId), thenonBack(). - Reuse
confirmEpisodeDeletefromstats/src/lib/delete-confirm.ts.
- New handler
- In
LibraryTab:useMediaLibraryreturns fresh data on mount. The simplest fix: pass arefreshfunction 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
libraryVersionkey on the library list. Preferrefreshfor clarity.
Tests
- Extend the existing
MediaDetailView.test.tsx: mockapiClient.deleteVideo, click the new button, confirmonBackfires after success. useMediaLibrary.test.ts: if we add arefreshmethod, 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:
where
<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>reading = fullReading(w.headword, w.reading)and differs fromheadword. - Keep
fullReadingimport fromreading-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
noteIds that are not in the resolved map. - Drop
cardEventswhosenoteIdslist was non-empty but is now empty after filtering. - Card events with a positive
cardsDeltabut nonoteIds(legacy rollup path) still render as+N cards— we have no way to cross-reference them, so leave them alone.
Implementation
- Compute
filteredCardEventsas auseMemodepending ondata.cardEventsandnoteInfos. - Iterate
filteredCardEventsinstead ofcardEventsin 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 stubsankiNotesInfoto return only a subset of notes and verifies the missing ones are not rendered.
Other call sites
- Grep so far shows
ankiNotesInfois only used inEpisodeDetail.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/04etc.). - 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):
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
CartesianGridfrom 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 equivalentYAxis.YAxisgainsaxisLine={{ stroke: CHART_THEME.axisLine }}.ResponsiveContainerheight changes from 120 →CHART_DEFAULTS.height.TooltipcontentStyleusesTOOLTIP_CONTENT_STYLE, and charts pass alabelFormatterwhen the label is a date key (e.g. showFri Apr 4).
Unit formatters
TrendChartalready accepts aformatterprop — 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).TrendChartsnapshot/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:
useMediaLibrarymay not expose arefreshfunction. 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
confirmBucketDeletewording 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:
confirmBucketDeletedoes not currently exist indelete-confirm.ts. Implementation either adds it or reusesconfirmDayGroupDeletewith 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.