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

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
Align the library cumulative trends filter UI with the new terminology by renaming the hardcoded anime visibility heading to title visibility.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
<!-- AC:END -->

View File

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

View File

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

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

View File

@@ -166,14 +166,20 @@ const TRENDS_DASHBOARD = {
ratios: { ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }], lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
}, },
animePerDay: { librarySummary: [
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], {
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], title: 'Little Witch Academia',
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], watchTimeMin: 25,
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }], videos: 1,
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }], sessions: 1,
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }], cards: 5,
}, words: 300,
lookups: 15,
lookupsPerHundred: 5,
firstWatched: 20_000,
lastWatched: 20_000,
},
],
animeCumulative: { animeCumulative: {
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
@@ -598,7 +604,23 @@ describe('stats server API routes', () => {
const body = await res.json(); const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']); assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); 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 () => { it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {

View File

@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
} }
async getTrendsDashboard( async getTrendsDashboard(
range: '7d' | '30d' | '90d' | 'all' = '30d', range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day', groupBy: 'day' | 'month' = 'day',
): Promise<unknown> { ): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy); return getTrendsDashboard(this.db, range, groupBy);

View File

@@ -687,7 +687,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75); assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18); assert.equal(dashboard.progress.lookups[1]?.value, 18);
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1)); 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.animeCumulative.watchTime[1]?.value, 75);
assert.equal( assert.equal(
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0), 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', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -3666,3 +3725,224 @@ test('deleteSession removes zero-session media from library and trends', () => {
cleanupDbPath(dbPath); 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);
}
});

View File

@@ -13,7 +13,7 @@ import {
} from './query-shared'; } from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions'; import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all'; type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month'; type TrendGroupBy = 'day' | 'month';
interface TrendChartPoint { interface TrendChartPoint {
@@ -27,6 +27,19 @@ interface TrendPerAnimePoint {
value: number; 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 { interface TrendSessionMetricRow {
startedAtMs: number; startedAtMs: number;
epochDay: number; epochDay: number;
@@ -61,14 +74,6 @@ export interface TrendsDashboardQueryResult {
ratios: { ratios: {
lookupsPerHundred: TrendChartPoint[]; lookupsPerHundred: TrendChartPoint[];
}; };
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
animeCumulative: { animeCumulative: {
watchTime: TrendPerAnimePoint[]; watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[];
@@ -79,12 +84,14 @@ export interface TrendsDashboardQueryResult {
watchTimeByDayOfWeek: TrendChartPoint[]; watchTimeByDayOfWeek: TrendChartPoint[];
watchTimeByHour: TrendChartPoint[]; watchTimeByHour: TrendChartPoint[];
}; };
librarySummary: LibrarySummaryRow[];
} }
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = { const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'7d': 7, '7d': 7,
'30d': 30, '30d': 30,
'90d': 90, '90d': 90,
'365d': 365,
}; };
const MONTH_NAMES = [ 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[] { function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
const byAnime = new Map<string, Map<number, number>>(); const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>(); const allDays = new Set<number>();
@@ -390,6 +342,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
return result; 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( function getVideoAnimeTitleMap(
db: DatabaseSync, db: DatabaseSync,
videoIds: Array<number | null>, videoIds: Array<number | null>,
@@ -662,8 +697,6 @@ export function getTrendsDashboard(
titlesByVideoId, titlesByVideoId,
(rollup) => rollup.totalTokensSeen, (rollup) => rollup.totalTokensSeen,
), ),
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
}; };
return { return {
@@ -690,7 +723,6 @@ export function getTrendsDashboard(
ratios: { ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
}, },
animePerDay,
animeCumulative: { animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime), watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes), episodes: buildCumulativePerAnime(animePerDay.episodes),
@@ -701,5 +733,6 @@ export function getTrendsDashboard(
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions), watchTimeByHour: buildWatchTimeByHour(sessions),
}, },
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
}; };
} }

View File

@@ -30,8 +30,10 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
} }
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' { function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d'; return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
? raw
: '30d';
} }
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {

View File

@@ -93,7 +93,7 @@ export function AnimeTab({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="text" type="text"
placeholder="Search anime..." placeholder="Search library..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" 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>
<div className="text-xs text-ctp-overlay2 shrink-0"> <div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} anime · {formatDuration(totalMs)} {filtered.length} titles · {formatDuration(totalMs)}
</div> </div>
</div> </div>
{filtered.length === 0 ? ( {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`}> <div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
{filtered.map((item) => ( {filtered.map((item) => (

View File

@@ -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');
});

View File

@@ -16,10 +16,32 @@ interface NoteInfo {
expression: string; 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) { export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
const [data, setData] = useState<EpisodeDetailData | null>(null); const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map()); const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
map.set(note.noteId, { noteId: note.noteId, expression: expr }); map.set(note.noteId, { noteId: note.noteId, expression: expr });
} }
setNoteInfos(map); 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(() => { .catch(() => {
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
const { sessions, cardEvents } = data; 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 ( return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg"> <div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
{sessions.length > 0 && ( {sessions.length > 0 && (
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div> </div>
)} )}
{cardEvents.length > 0 && ( {filteredCardEvents.length > 0 && (
<div className="p-3 border-b border-ctp-surface1"> <div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4> <h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
<div className="space-y-1.5"> <div className="space-y-1.5">
{cardEvents.map((ev) => ( {filteredCardEvents.map((ev) => (
<div key={ev.eventId} className="flex items-center gap-2 text-xs"> <div key={ev.eventId} className="flex items-center gap-2 text-xs">
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span> <span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
{ev.noteIds.length > 0 ? ( {ev.noteIds.length > 0 ? (
@@ -144,6 +182,12 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div> </div>
))} ))}
</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> </div>
)} )}

View File

@@ -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>
);
}

View File

@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
assert.equal( assert.equal(
@@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
'View Anime', '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');
});

View File

@@ -1,12 +1,48 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail'; import { useMediaDetail } from '../../hooks/useMediaDetail';
import { apiClient } from '../../lib/api-client'; 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 { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { MediaHeader } from './MediaHeader'; import { MediaHeader } from './MediaHeader';
import { MediaSessionList } from './MediaSessionList'; import { MediaSessionList } from './MediaSessionList';
import type { MediaDetailData, SessionSummary } from '../../types/stats'; 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 { export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
if (detail?.channelName?.trim()) { if (detail?.channelName?.trim()) {
return 'View Channel'; return 'View Channel';
@@ -35,6 +71,8 @@ export function MediaDetailView({
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null); const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null); const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
const isDeletingEpisodeRef = useRef(false);
useEffect(() => { useEffect(() => {
setLocalSessions(data?.sessions ?? null); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -99,7 +148,11 @@ export function MediaDetailView({
</button> </button>
) : null} ) : null}
</div> </div>
<MediaHeader detail={detail} /> <MediaHeader
detail={detail}
onDeleteEpisode={handleDeleteEpisode}
isDeletingEpisode={isDeletingEpisode}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null} {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList <MediaSessionList
sessions={sessions} sessions={sessions}

View File

@@ -12,9 +12,16 @@ interface MediaHeaderProps {
totalUniqueWords: number; totalUniqueWords: number;
knownWordCount: number; knownWordCount: number;
} | null; } | null;
onDeleteEpisode?: () => void;
isDeletingEpisode?: boolean;
} }
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) { export function MediaHeader({
detail,
initialKnownWordsSummary = null,
onDeleteEpisode,
isDeletingEpisode = false,
}: MediaHeaderProps) {
const knownTokenRate = const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs = const avgSessionMs =
@@ -50,7 +57,21 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
className="w-32 h-44 rounded-lg shrink-0" className="w-32 h-44 rounded-lg shrink-0"
/> />
<div className="flex-1 min-w-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 ? ( {detail.channelName ? (
<div className="mt-1 text-sm text-ctp-subtext1 truncate"> <div className="mt-1 text-sm text-ctp-subtext1 truncate">
{detail.channelUrl ? ( {detail.channelUrl ? (

View File

@@ -36,7 +36,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
/> />
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" /> <StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
<StatCard <StatCard
label="Active Anime" label="Active Titles"
value={formatNumber(summary.activeAnimeCount)} value={formatNumber(summary.activeAnimeCount)}
color="text-ctp-mauve" color="text-ctp-mauve"
/> />

View File

@@ -71,7 +71,7 @@ export function TrackingSnapshot({
</div> </div>
</div> </div>
</Tooltip> </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="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div> <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"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
</div> </div>
</div> </div>
</Tooltip> </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="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"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)} {formatNumber(summary.totalAnimeCompleted)}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; 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 { 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'; import type { DailyRollup } from '../../types/stats';
interface WatchTimeChartProps { interface WatchTimeChartProps {
@@ -52,28 +52,23 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
))} ))}
</div> </div>
</div> </div>
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<BarChart data={chartData}> <BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis <XAxis
dataKey="date" dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
/> />
<YAxis <YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
width={30} width={32}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={TOOLTIP_CONTENT_STYLE}
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
labelStyle={{ color: CHART_THEME.tooltipLabel }} labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes} formatter={formatActiveMinutes}
/> />

View File

@@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) {
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0; const hasKnownWords = knownWordsMap.size > 0;
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } = const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
buildSessionChartEvents(events); buildSessionChartEvents(events);
const lookupRate = buildLookupRateDisplay( const lookupRate = buildLookupRateDisplay(
session.yomitanLookupCount, session.yomitanLookupCount,
getSessionDisplayWordCount(session), getSessionDisplayWordCount(session),
); );
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length; const cardEventCount = cardEvents.length;
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey); const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
const activeMarker = useMemo<SessionChartMarker | null>( const activeMarker = useMemo<SessionChartMarker | null>(
@@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
sorted={sorted} sorted={sorted}
knownWordsMap={knownWordsMap} knownWordsMap={knownWordsMap}
cardEvents={cardEvents} cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents} yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions} pauseRegions={pauseRegions}
markers={markers} markers={markers}
@@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds} loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote} onOpenNote={handleOpenNote}
pauseCount={pauseCount} pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount} cardEventCount={cardEventCount}
lookupRate={lookupRate} lookupRate={lookupRate}
session={session} session={session}
@@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
<FallbackView <FallbackView
sorted={sorted} sorted={sorted}
cardEvents={cardEvents} cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents} yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions} pauseRegions={pauseRegions}
markers={markers} markers={markers}
@@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds} loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote} onOpenNote={handleOpenNote}
pauseCount={pauseCount} pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount} cardEventCount={cardEventCount}
lookupRate={lookupRate} lookupRate={lookupRate}
session={session} session={session}
@@ -280,7 +275,6 @@ function RatioView({
sorted, sorted,
knownWordsMap, knownWordsMap,
cardEvents, cardEvents,
seekEvents,
yomitanLookupEvents, yomitanLookupEvents,
pauseRegions, pauseRegions,
markers, markers,
@@ -292,7 +286,6 @@ function RatioView({
loadingNoteIds, loadingNoteIds,
onOpenNote, onOpenNote,
pauseCount, pauseCount,
seekCount,
cardEventCount, cardEventCount,
lookupRate, lookupRate,
session, session,
@@ -300,7 +293,6 @@ function RatioView({
sorted: TimelineEntry[]; sorted: TimelineEntry[];
knownWordsMap: Map<number, number>; knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[]; cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>; pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[]; markers: SessionChartMarker[];
@@ -312,7 +304,6 @@ function RatioView({
loadingNoteIds: Set<number>; loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void; onOpenNote: (noteId: number) => void;
pauseCount: number; pauseCount: number;
seekCount: number;
cardEventCount: number; cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>; lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary; 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 */} {/* Yomitan lookup markers */}
{yomitanLookupEvents.map((e, i) => ( {yomitanLookupEvents.map((e, i) => (
<ReferenceLine <ReferenceLine
@@ -549,7 +524,6 @@ function RatioView({
<StatsBar <StatsBar
hasKnownWords hasKnownWords
pauseCount={pauseCount} pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount} cardEventCount={cardEventCount}
session={session} session={session}
lookupRate={lookupRate} lookupRate={lookupRate}
@@ -563,7 +537,6 @@ function RatioView({
function FallbackView({ function FallbackView({
sorted, sorted,
cardEvents, cardEvents,
seekEvents,
yomitanLookupEvents, yomitanLookupEvents,
pauseRegions, pauseRegions,
markers, markers,
@@ -575,14 +548,12 @@ function FallbackView({
loadingNoteIds, loadingNoteIds,
onOpenNote, onOpenNote,
pauseCount, pauseCount,
seekCount,
cardEventCount, cardEventCount,
lookupRate, lookupRate,
session, session,
}: { }: {
sorted: TimelineEntry[]; sorted: TimelineEntry[];
cardEvents: SessionEvent[]; cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>; pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[]; markers: SessionChartMarker[];
@@ -594,7 +565,6 @@ function FallbackView({
loadingNoteIds: Set<number>; loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void; onOpenNote: (noteId: number) => void;
pauseCount: number; pauseCount: number;
seekCount: number;
cardEventCount: number; cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>; lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary; session: SessionSummary;
@@ -680,20 +650,6 @@ function FallbackView({
strokeOpacity={0.8} 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) => ( {yomitanLookupEvents.map((e, i) => (
<ReferenceLine <ReferenceLine
key={`yomitan-${i}`} key={`yomitan-${i}`}
@@ -735,7 +691,6 @@ function FallbackView({
<StatsBar <StatsBar
hasKnownWords={false} hasKnownWords={false}
pauseCount={pauseCount} pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount} cardEventCount={cardEventCount}
session={session} session={session}
lookupRate={lookupRate} lookupRate={lookupRate}
@@ -749,14 +704,12 @@ function FallbackView({
function StatsBar({ function StatsBar({
hasKnownWords, hasKnownWords,
pauseCount, pauseCount,
seekCount,
cardEventCount, cardEventCount,
session, session,
lookupRate, lookupRate,
}: { }: {
hasKnownWords: boolean; hasKnownWords: boolean;
pauseCount: number; pauseCount: number;
seekCount: number;
cardEventCount: number; cardEventCount: number;
session: SessionSummary; session: SessionSummary;
lookupRate: ReturnType<typeof buildLookupRateDisplay>; lookupRate: ReturnType<typeof buildLookupRateDisplay>;
@@ -791,12 +744,7 @@ function StatsBar({
{pauseCount !== 1 ? 's' : ''} {pauseCount !== 1 ? 's' : ''}
</span> </span>
)} )}
{seekCount > 0 && ( {pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
<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>}
{/* Group 3: Learning events */} {/* Group 3: Learning events */}
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">

View File

@@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) { switch (marker.kind) {
case 'pause': case 'pause':
return '||'; return '||';
case 'seek':
return marker.direction === 'backward' ? '<<' : '>>';
case 'card': case 'card':
return '\u26CF'; return '\u26CF';
} }
@@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
switch (marker.kind) { switch (marker.kind) {
case 'pause': case 'pause':
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' }; 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': case 'card':
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' }; return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
} }

View File

@@ -41,35 +41,6 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
assert.match(markup, /Open in Anki/); 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', () => { test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
const marker: SessionChartMarker = { const marker: SessionChartMarker = {
key: 'card-9000', key: 'card-9000',

View File

@@ -31,18 +31,12 @@ export function SessionEventPopover({
onClose, onClose,
onOpenNote, onOpenNote,
}: SessionEventPopoverProps) { }: SessionEventPopoverProps) {
const seekDurationLabel =
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
: null;
return ( 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="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 className="mb-2 flex items-start justify-between gap-3">
<div> <div>
<div className="text-xs font-semibold text-ctp-text"> <div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'} {marker.kind === 'pause' && 'Paused'}
{marker.kind === 'seek' && `Seek ${marker.direction}`}
{marker.kind === 'card' && 'Card mined'} {marker.kind === 'card' && 'Card mined'}
</div> </div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div> <div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
@@ -72,7 +66,6 @@ export function SessionEventPopover({
) : null} ) : null}
<div className="text-sm"> <div className="text-sm">
{marker.kind === 'pause' && '||'} {marker.kind === 'pause' && '||'}
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
{marker.kind === 'card' && '\u26CF'} {marker.kind === 'card' && '\u26CF'}
</div> </div>
</div> </div>
@@ -84,19 +77,6 @@ export function SessionEventPopover({
</div> </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' && ( {marker.kind === 'card' && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs text-ctp-cards-mined"> <div className="text-xs text-ctp-cards-mined">

View File

@@ -120,7 +120,7 @@ export function SessionRow({
}} }}
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`} 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" 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'} {'\u2197'}
</button> </button>

View File

@@ -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');
});

View File

@@ -3,8 +3,9 @@ import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow'; import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail'; import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm'; import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatSessionDayLabel } from '../../lib/formatters'; import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> { function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
@@ -23,6 +24,35 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
return groups; 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 { interface SessionsTabProps {
initialSessionId?: number | null; initialSessionId?: number | null;
onClearInitialSession?: () => void; onClearInitialSession?: () => void;
@@ -36,10 +66,12 @@ export function SessionsTab({
}: SessionsTabProps = {}) { }: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions(); const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]); const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null); const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setVisibleSessions(sessions); setVisibleSessions(sessions);
@@ -76,7 +108,16 @@ export function SessionsTab({
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q)); return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
}, [visibleSessions, search]); }, [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) => { const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return; 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 (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</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} {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => ( {Array.from(dayGroups.entries()).map(([dayLabel, daySessions]) => {
<div key={dayLabel}> const buckets = groupSessionsByVideo(daySessions);
<div className="flex items-center gap-3 mb-2"> return (
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0"> <div key={dayLabel}>
{dayLabel} <div className="flex items-center gap-3 mb-2">
</h3> <h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" /> {dayLabel}
</div> </h3>
<div className="space-y-2"> <div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
{daySessions.map((s) => { </div>
const detailsId = `session-details-${s.sessionId}`; <div className="space-y-2">
return ( {buckets.map((bucket) => {
<div key={s.sessionId}> if (bucket.sessions.length === 1) {
<SessionRow const s = bucket.sessions[0]!;
session={s} const detailsId = `session-details-${s.sessionId}`;
isExpanded={expandedId === s.sessionId} return (
detailsId={detailsId} <div key={bucket.key}>
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} <SessionRow
onDelete={() => void handleDeleteSession(s)} session={s}
deleteDisabled={deletingSessionId === s.sessionId} isExpanded={expandedId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail} detailsId={detailsId}
/> onToggle={() =>
{expandedId === s.sessionId && ( setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
<div id={detailsId}> }
<SessionDetail session={s} /> onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</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>
</div> );
))} })}
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="text-ctp-overlay2 text-sm"> <div className="text-ctp-overlay2 text-sm">

View File

@@ -53,7 +53,7 @@ export function DateRangeSelector({
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<SegmentedControl <SegmentedControl
label="Range" label="Range"
options={['7d', '30d', '90d', 'all'] as TimeRange[]} options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
value={range} value={range}
onChange={onRangeChange} onChange={onRangeChange}
formatLabel={(r) => (r === 'all' ? 'All' : r)} formatLabel={(r) => (r === 'all' ? 'All' : r)}

View File

@@ -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>
</>
);
}

View File

@@ -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'; import { epochDayToDate } from '../../lib/formatters';
export interface PerAnimeDataPoint { export interface PerAnimeDataPoint {
@@ -64,14 +73,6 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
const { points, seriesKeys } = buildLineData(data); const { points, seriesKeys } = buildLineData(data);
const colors = colorPalette ?? DEFAULT_LINE_COLORS; const colors = colorPalette ?? DEFAULT_LINE_COLORS;
const tooltipStyle = {
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
if (points.length === 0) { if (points.length === 0) {
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <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 ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <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> <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}>
<AreaChart data={points}> <AreaChart data={points} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis <XAxis
dataKey="label" dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
/> />
<YAxis <YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
width={28} width={32}
/> />
<Tooltip contentStyle={tooltipStyle} /> <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
{seriesKeys.map((key, i) => ( {seriesKeys.map((key, i) => (
<Area <Area
key={key} key={key}

View File

@@ -6,8 +6,10 @@ import {
XAxis, XAxis,
YAxis, YAxis,
Tooltip, Tooltip,
CartesianGrid,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
interface TrendChartProps { interface TrendChartProps {
title: string; title: string;
@@ -19,35 +21,29 @@ interface TrendChartProps {
} }
export function TrendChart({ title, data, color, type, formatter, onBarClick }: 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]); const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <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> <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' ? ( {type === 'bar' ? (
<BarChart data={data}> <BarChart data={data} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis <XAxis
dataKey="label" dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
/> />
<YAxis <YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
width={28} width={32}
tickFormatter={formatter}
/> />
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} /> <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
<Bar <Bar
dataKey="value" dataKey="value"
fill={color} fill={color}
@@ -59,20 +55,22 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
/> />
</BarChart> </BarChart>
) : ( ) : (
<LineChart data={data}> <LineChart data={data} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis <XAxis
dataKey="label" dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} tickLine={false}
/> />
<YAxis <YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }} tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={false} axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false} 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} /> <Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
</LineChart> </LineChart>
)} )}

View File

@@ -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/);
});

View File

@@ -8,6 +8,7 @@ import {
filterHiddenAnimeData, filterHiddenAnimeData,
pruneHiddenAnime, pruneHiddenAnime,
} from './anime-visibility'; } from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection';
function SectionHeader({ children }: { children: React.ReactNode }) { function SectionHeader({ children }: { children: React.ReactNode }) {
return ( return (
@@ -28,7 +29,7 @@ interface AnimeVisibilityFilterProps {
onToggleAnime: (title: string) => void; onToggleAnime: (title: string) => void;
} }
function AnimeVisibilityFilter({ export function AnimeVisibilityFilter({
animeTitles, animeTitles,
hiddenAnime, hiddenAnime,
onShowAll, onShowAll,
@@ -44,7 +45,7 @@ function AnimeVisibilityFilter({
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<div> <div>
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0"> <h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
Anime Visibility Title Visibility
</h4> </h4>
<p className="mt-1 text-xs text-ctp-overlay1"> <p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything. Shared across all anime trend charts. Default: show everything.
@@ -114,11 +115,6 @@ export function TrendsTab() {
if (!data) return null; if (!data) return null;
const animeTitles = buildAnimeVisibilityOptions([ const animeTitles = buildAnimeVisibilityOptions([
data.animePerDay.episodes,
data.animePerDay.watchTime,
data.animePerDay.cards,
data.animePerDay.words,
data.animePerDay.lookups,
data.animeCumulative.episodes, data.animeCumulative.episodes,
data.animeCumulative.cards, data.animeCumulative.cards,
data.animeCumulative.words, data.animeCumulative.words,
@@ -126,24 +122,6 @@ export function TrendsTab() {
]); ]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles); 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( const filteredAnimeProgress = filterHiddenAnimeData(
data.animeCumulative.episodes, data.animeCumulative.episodes,
activeHiddenAnime, activeHiddenAnime,
@@ -185,6 +163,18 @@ export function TrendsTab() {
/> />
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" /> <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="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> <SectionHeader>Period Trends</SectionHeader>
<TrendChart <TrendChart
@@ -221,7 +211,7 @@ export function TrendsTab() {
type="line" type="line"
/> />
<SectionHeader>Anime Per Day</SectionHeader> <SectionHeader>Library Cumulative</SectionHeader>
<AnimeVisibilityFilter <AnimeVisibilityFilter
animeTitles={animeTitles} animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime} 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="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} /> <StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart <StackedTrendChart
@@ -263,19 +238,8 @@ export function TrendsTab() {
/> />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} /> <StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader> <SectionHeader>Library Summary</SectionHeader>
<TrendChart <LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
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"
/>
</div> </div>
</div> </div>
); );

View File

@@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({
> >
{'\u25B6'} {'\u25B6'}
</span> </span>
Words In Multiple Anime Words Across Multiple Titles
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasKnownData && ( {hasKnownData && (
@@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({
{collapsed ? null : ranked.length === 0 ? ( {collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3"> <div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown {hideKnown
? 'All multi-anime words are already known!' ? 'All words that span multiple titles are already known!'
: 'No words found across multiple anime.'} : 'No words found across multiple titles.'}
</div> </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">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</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-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> <th className="text-right py-2 font-medium w-16">Seen</th>
</tr> </tr>
</thead> </thead>

View File

@@ -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',
);
});

View File

@@ -113,7 +113,6 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1"> <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 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">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-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 font-medium w-20">Seen</th> <th className="text-right py-2 font-medium w-20">Seen</th>
</tr> </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"> <td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()} #{w.frequencyRank!.toLocaleString()}
</td> </td>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td> <td className="py-1.5 pr-3">
<td className="py-1.5 pr-3 text-ctp-subtext0"> <span className="text-ctp-text font-medium">{w.headword}</span>
{fullReading(w.headword, w.reading) || w.headword} {(() => {
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>
<td className="py-1.5 pr-3"> <td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />} {w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}

View File

@@ -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,
);
});

View File

@@ -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 };
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { TrendsDashboardData } from '../types/stats'; 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 type GroupBy = 'day' | 'month';
export function useTrends(range: TimeRange, groupBy: GroupBy) { export function useTrends(range: TimeRange, groupBy: GroupBy) {

View File

@@ -84,14 +84,7 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
lookups: [], lookups: [],
}, },
ratios: { lookupsPerHundred: [] }, ratios: { lookupsPerHundred: [] },
animePerDay: { librarySummary: [],
episodes: [],
watchTime: [],
cards: [],
words: [],
lookups: [],
lookupsPerHundred: [],
},
animeCumulative: { animeCumulative: {
watchTime: [], watchTime: [],
episodes: [], 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 () => { test('getSessionEvents can request only specific event types', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let seenUrl = ''; let seenUrl = '';

View File

@@ -116,7 +116,7 @@ export const apiClient = {
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`), fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) => getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), 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>( fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`, `/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
), ),

View File

@@ -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));
});

View File

@@ -5,4 +5,21 @@ export const CHART_THEME = {
tooltipText: '#cad3f5', tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0', tooltipLabel: '#b8c0e0',
barFill: '#8aadf4', barFill: '#8aadf4',
grid: '#494d64',
axisLine: '#494d64',
} as const; } 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,
};

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
confirmBucketDelete,
confirmDayGroupDelete, confirmDayGroupDelete,
confirmEpisodeDelete, confirmEpisodeDelete,
confirmSessionDelete, 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', () => { test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;

View File

@@ -17,3 +17,14 @@ export function confirmAnimeGroupDelete(title: string, count: number): boolean {
export function confirmEpisodeDelete(title: string): boolean { export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`); 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?`,
);
}

View File

@@ -46,9 +46,10 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' }, { eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
]); ]);
// Seek events are intentionally dropped from the chart — they were too noisy.
assert.deepEqual( assert.deepEqual(
chartEvents.seekEvents.map((event) => event.eventType), chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD], [],
); );
assert.deepEqual( assert.deepEqual(
chartEvents.cardEvents.map((event) => event.tsMs), chartEvents.cardEvents.map((event) => event.tsMs),

View File

@@ -29,25 +29,20 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null }, { eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]); ]);
// Seek events are intentionally dropped — too noisy on the session chart.
assert.deepEqual( assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind), chartEvents.markers.map((marker) => marker.kind),
['seek', 'pause', 'card'], ['pause', 'card'],
); );
const seekMarker = chartEvents.markers[0]!; const pauseMarker = 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]!;
assert.equal(pauseMarker.kind, 'pause'); assert.equal(pauseMarker.kind, 'pause');
assert.equal(pauseMarker.startMs, 2_000); assert.equal(pauseMarker.startMs, 2_000);
assert.equal(pauseMarker.endMs, 5_000); assert.equal(pauseMarker.endMs, 5_000);
assert.equal(pauseMarker.durationMs, 3_000); assert.equal(pauseMarker.durationMs, 3_000);
assert.equal(pauseMarker.anchorTsMs, 3_500); assert.equal(pauseMarker.anchorTsMs, 3_500);
const cardMarker = chartEvents.markers[2]!; const cardMarker = chartEvents.markers[1]!;
assert.equal(cardMarker.kind, 'card'); assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]); assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2); assert.equal(cardMarker.cardsDelta, 2);

View File

@@ -2,8 +2,6 @@ import { EventType, type SessionEvent } from '../types/stats';
export const SESSION_CHART_EVENT_TYPES = [ export const SESSION_CHART_EVENT_TYPES = [
EventType.CARD_MINED, EventType.CARD_MINED,
EventType.SEEK_FORWARD,
EventType.SEEK_BACKWARD,
EventType.PAUSE_START, EventType.PAUSE_START,
EventType.PAUSE_END, EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP, EventType.YOMITAN_LOOKUP,
@@ -16,7 +14,6 @@ export interface PauseRegion {
export interface SessionChartEvents { export interface SessionChartEvents {
cardEvents: SessionEvent[]; cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[]; pauseRegions: PauseRegion[];
markers: SessionChartMarker[]; markers: SessionChartMarker[];
@@ -58,15 +55,6 @@ export type SessionChartMarker =
endMs: number; endMs: number;
durationMs: number; durationMs: number;
} }
| {
key: string;
kind: 'seek';
anchorTsMs: number;
eventTsMs: number;
direction: 'forward' | 'backward';
fromMs: number | null;
toMs: number | null;
}
| { | {
key: string; key: string;
kind: 'card'; kind: 'card';
@@ -295,7 +283,6 @@ export function projectSessionMarkerLeftPx({
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents { export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = []; const cardEvents: SessionEvent[] = [];
const seekEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = []; const yomitanLookupEvents: SessionEvent[] = [];
const pauseRegions: PauseRegion[] = []; const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = []; const markers: SessionChartMarker[] = [];
@@ -317,22 +304,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
}); });
} }
break; 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: case EventType.YOMITAN_LOOKUP:
yomitanLookupEvents.push(event); yomitanLookupEvents.push(event);
break; break;
@@ -376,7 +347,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
return { return {
cardEvents, cardEvents,
seekEvents,
yomitanLookupEvents, yomitanLookupEvents,
pauseRegions, pauseRegions,
markers, markers,

View File

@@ -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);
}
});

View File

@@ -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;
}

View File

@@ -288,6 +288,19 @@ export interface TrendPerAnimePoint {
value: number; 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 { export interface TrendsDashboardData {
activity: { activity: {
watchTime: TrendChartPoint[]; watchTime: TrendChartPoint[];
@@ -307,14 +320,7 @@ export interface TrendsDashboardData {
ratios: { ratios: {
lookupsPerHundred: TrendChartPoint[]; lookupsPerHundred: TrendChartPoint[];
}; };
animePerDay: { librarySummary: LibrarySummaryRow[];
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
animeCumulative: { animeCumulative: {
watchTime: TrendPerAnimePoint[]; watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[];