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
@@ -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 -->
+11
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.
+4
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
@@ -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.
@@ -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.`
@@ -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 () => {
@@ -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);
@@ -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);
}
});
@@ -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),
}; };
} }
+4 -2
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' {
+3 -3
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) => (
@@ -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');
});
+47 -3
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>
)} )}
-120
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>
);
}
@@ -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');
});
@@ -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}
+23 -2
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 ? (
+1 -1
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"
/> />
@@ -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>
@@ -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}
/> />
@@ -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">
@@ -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' };
} }
@@ -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',
@@ -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">
+1 -1
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>
@@ -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');
});
+183 -34
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">
@@ -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)}
@@ -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>
</>
);
}
@@ -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}
+21 -23
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>
)} )}
@@ -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/);
});
+18 -54
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>
); );
@@ -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>
@@ -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',
);
});
@@ -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} />}
-57
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,
);
});
-65
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 };
}
+1 -1
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) {
+43 -8
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 = '';
+1 -1
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)}`,
), ),
+16
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));
});
+17
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,
};
+37
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;
+11
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?`,
);
}
+3 -2
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),
+4 -9
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);
-30
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,
+96
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);
}
});
+43
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;
}
+14 -8
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[];