feat(stats): dashboard updates (#50)

This commit is contained in:
2026-04-10 02:46:50 -07:00
committed by GitHub
parent 9b4de93283
commit 05cf4a6fe5
53 changed files with 5250 additions and 660 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -0,0 +1,347 @@
# Stats Dashboard Feedback Pass — Design
Date: 2026-04-09
Scope: Stats dashboard UX follow-ups from user feedback (items 17).
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.`