Compare commits

...

15 Commits

Author SHA1 Message Date
sudacode c5e778d7d2 feat(stats): use generic title wording for mixed-source library
The stats dashboard now supports both anime series and YouTube videos
in the same library, so the anime-only copy no longer fits. Rename
user-visible labels ("Active Anime", "Search anime…", "Anime — Per
Day", "Episodes per Anime", "Words In Multiple Anime", etc.) to use
"Title"/"Library" wording that covers either source.

Data-model names (animeId, animeCount, useAnimeLibrary) stay as-is;
this pass only touches strings the user actually reads.
2026-04-09 01:55:49 -07:00
sudacode b1acbae580 refactor(stats): drop unused LibraryTab and useMediaLibrary
The live "Library" tab renders AnimeTab via App.tsx; LibraryTab and its
useMediaLibrary hook were never wired in. Remove them so the dead code
doesn't mislead readers, and drop the collapsible-library-group item
from the feedback-pass changelog since it targeted dead code.
2026-04-09 01:55:37 -07:00
sudacode 45d30ea66c docs(changelog): summarize stats dashboard feedback pass 2026-04-09 01:25:38 -07:00
sudacode b080add6ce feat(stats): unify chart theme and add gridlines for legibility 2026-04-09 01:24:48 -07:00
sudacode b4aea0f77e feat(stats): roll up same-episode sessions within a day
Group sessions for the same video inside each day header so repeated
viewings of an episode collapse into a single expandable bucket with
aggregate active time and card counts. Multi-session buckets get a
dedicated delete action that wipes every session in the group via the
bulk delete endpoint; singleton buckets continue to render the existing
SessionRow flow unchanged.

Delete confirmation copy lives in delete-confirm for parity with the
other bulk-delete flows, and the bucket delete path is extracted into a
pure buildBucketDeleteHandler factory so it can be unit-tested without
rendering the tab. MediaSessionList is intentionally untouched -- it is
already scoped to a single video and doesn't need rollup.
2026-04-09 01:19:30 -07:00
sudacode 6dcf7d9234 feat(stats): add groupSessionsByVideo helper for episode rollups
Pure TS helper that buckets SessionSummary[] by videoId for the
Sessions tab collapsible UI (Task 8). Null videoIds become singleton
buckets keyed by sessionId. Covered by 4 node:test cases.
2026-04-09 01:09:49 -07:00
sudacode cfb2396791 feat(stats): collapsible series groups in library tab 2026-04-09 01:07:25 -07:00
sudacode 8e25e19cac feat(stats): delete episode from library detail view
Add Delete Episode button to MediaDetailView/MediaHeader; extract
buildDeleteEpisodeHandler for testability. Add refresh() to
useMediaLibrary (version-bump pattern) and call it in LibraryTab's
onBack so the list reloads after a delete.
2026-04-09 01:01:13 -07:00
sudacode 20976d63f0 fix(stats): hide cards deleted from Anki in episode detail
Filters out noteIds whose Anki note no longer exists, drops card events
that have no surviving noteIds, and shows a muted footer counting hidden
cards. Loading-state guard (noteInfosLoaded) prevents premature filtering
before ankiNotesInfo resolves.
2026-04-09 00:53:39 -07:00
sudacode c1bc92f254 fix(stats): collapse word and reading into one column in Top 50 table 2026-04-09 00:48:15 -07:00
sudacode 364f7aacb7 feat(stats): expose 365d trends range in dashboard UI
Add '365d' to the client TrendRange union, the TimeRange hook type, and
the DateRangeSelector segmented control so users can select a 365-day
window in the trends dashboard.
2026-04-09 00:44:31 -07:00
sudacode 76547bb96e feat(stats): allow 365d trends range in HTTP route 2026-04-09 00:42:11 -07:00
sudacode 409a3964d2 feat(stats): support 365d range in trends query 2026-04-09 00:33:23 -07:00
sudacode 8874e2e1c6 docs: add stats dashboard feedback pass implementation plan
Task-by-task TDD plan for the seven items in the matching design
spec, organized as eleven commits in a single PR (365d backend,
server allow-list, frontend selector, vocabulary column,
Anki-deleted card filter, library episode delete, collapsible
library groups, session grouping helper, sessions tab rollup,
chart clarity pass, changelog fragment).
2026-04-09 00:28:41 -07:00
sudacode 82d58a57c6 docs: add stats dashboard feedback pass design spec
Design for a single PR covering seven stats dashboard items:
collapsible library series groups, same-episode session rollups,
365d trend range, episode delete in library detail, tighter
vocabulary word/reading column, filtering Anki-deleted cards, and
a chart clarity pass with shared theming.
2026-04-09 00:20:48 -07:00
39 changed files with 3003 additions and 371 deletions
+10
View File
@@ -0,0 +1,10 @@
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.
File diff suppressed because it is too large Load Diff
@@ -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.`
@@ -601,6 +601,22 @@ describe('stats server API routes', () => {
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
});
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 () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
}
async getTrendsDashboard(
range: '7d' | '30d' | '90d' | 'all' = '30d',
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy);
@@ -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', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -13,7 +13,7 @@ import {
} from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all';
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month';
interface TrendChartPoint {
@@ -85,6 +85,7 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'365d': 365,
};
const MONTH_NAMES = [
+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);
}
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
? raw
: '30d';
}
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">
<input
type="text"
placeholder="Search anime..."
placeholder="Search library..."
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"
@@ -125,12 +125,12 @@ export function AnimeTab({
))}
</div>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} anime · {formatDuration(totalMs)}
{filtered.length} titles · {formatDuration(totalMs)}
</div>
</div>
{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`}>
{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');
});
+46 -3
View File
@@ -16,10 +16,32 @@ interface NoteInfo {
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) {
const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
map.set(note.noteId, { noteId: note.noteId, expression: expr });
}
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(() => {
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
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 (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
{sessions.length > 0 && (
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</div>
)}
{cardEvents.length > 0 && (
{filteredCardEvents.length > 0 && (
<div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
<div className="space-y-1.5">
{cardEvents.map((ev) => (
{filteredCardEvents.map((ev) => (
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
{ev.noteIds.length > 0 ? (
@@ -144,6 +182,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</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>
)}
-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 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', () => {
assert.equal(
@@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
'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,34 @@
import { useEffect, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail';
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 { MediaHeader } from './MediaHeader';
import { MediaSessionList } from './MediaSessionList';
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;
}
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
return async () => {
if (!opts.confirmFn(opts.title)) return;
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.');
}
};
}
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
if (detail?.channelName?.trim()) {
return 'View Channel';
@@ -79,6 +101,15 @@ export function MediaDetailView({
}
};
const handleDeleteEpisode = buildDeleteEpisodeHandler({
videoId,
title: detail.canonicalTitle,
apiClient,
confirmFn: confirmEpisodeDelete,
onBack,
setDeleteError,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -99,7 +130,7 @@ export function MediaDetailView({
</button>
) : null}
</div>
<MediaHeader detail={detail} />
<MediaHeader detail={detail} onDeleteEpisode={handleDeleteEpisode} />
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList
sessions={sessions}
+17 -1
View File
@@ -12,9 +12,14 @@ interface MediaHeaderProps {
totalUniqueWords: number;
knownWordCount: number;
} | null;
onDeleteEpisode?: () => void;
}
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
export function MediaHeader({
detail,
initialKnownWordsSummary = null,
onDeleteEpisode,
}: MediaHeaderProps) {
const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs =
@@ -50,7 +55,18 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
{onDeleteEpisode != null ? (
<button
type="button"
onClick={onDeleteEpisode}
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity"
>
Delete Episode
</button>
) : null}
</div>
{detail.channelName ? (
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
{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="Active Anime"
label="Active Titles"
value={formatNumber(summary.activeAnimeCount)}
color="text-ctp-mauve"
/>
@@ -71,7 +71,7 @@ export function TrackingSnapshot({
</div>
</div>
</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="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">
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
</div>
</div>
</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="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">
{formatNumber(summary.totalAnimeCompleted)}
</div>
@@ -1,7 +1,15 @@
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 { CHART_THEME } from '../../lib/chart-theme';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import type { DailyRollup } from '../../types/stats';
interface WatchTimeChartProps {
@@ -52,28 +60,23 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
))}
</div>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={30}
width={32}
/>
<Tooltip
contentStyle={{
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
contentStyle={TOOLTIP_CONTENT_STYLE}
labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes}
/>
+1 -1
View File
@@ -120,7 +120,7 @@ export function SessionRow({
}}
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"
title="View anime overview"
title="View in Library"
>
{'\u2197'}
</button>
@@ -0,0 +1,156 @@
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');
});
+158 -7
View File
@@ -3,8 +3,9 @@ import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { formatSessionDayLabel } from '../../lib/formatters';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
@@ -23,6 +24,35 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
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 {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
@@ -36,10 +66,12 @@ export function SessionsTab({
}: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
useEffect(() => {
setVisibleSessions(sessions);
@@ -76,7 +108,16 @@ export function SessionsTab({
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
}, [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) => {
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 (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -110,7 +178,9 @@ export function SessionsTab({
{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]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
@@ -119,7 +189,78 @@ export function SessionsTab({
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{daySessions.map((s) => {
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={bucket.key}>
<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>
);
}
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}>
@@ -127,7 +268,11 @@ export function SessionsTab({
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onToggle={() =>
setExpandedId(
expandedId === s.sessionId ? null : s.sessionId,
)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
@@ -141,8 +286,14 @@ export function SessionsTab({
);
})}
</div>
)}
</div>
))}
);
})}
</div>
</div>
);
})}
{filtered.length === 0 && (
<div className="text-ctp-overlay2 text-sm">
@@ -53,7 +53,7 @@ export function DateRangeSelector({
<div className="flex items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
value={range}
onChange={onRangeChange}
formatLabel={(r) => (r === 'all' ? 'All' : r)}
@@ -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';
export interface PerAnimeDataPoint {
@@ -64,14 +73,6 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
const { points, seriesKeys } = buildLineData(data);
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
const tooltipStyle = {
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
if (points.length === 0) {
return (
<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 (
<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>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={points}>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={28}
width={32}
/>
<Tooltip contentStyle={tooltipStyle} />
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
{seriesKeys.map((key, i) => (
<Area
key={key}
+21 -23
View File
@@ -6,8 +6,10 @@ import {
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from 'recharts';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
interface TrendChartProps {
title: string;
@@ -19,35 +21,29 @@ interface 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]);
return (
<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>
<ResponsiveContainer width="100%" height={120}>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
{type === 'bar' ? (
<BarChart data={data}>
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={28}
width={32}
tickFormatter={formatter}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
<Bar
dataKey="value"
fill={color}
@@ -59,20 +55,22 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
/>
</BarChart>
) : (
<LineChart data={data}>
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
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} />
</LineChart>
)}
+8 -8
View File
@@ -221,7 +221,7 @@ export function TrendsTab() {
type="line"
/>
<SectionHeader>Anime Per Day</SectionHeader>
<SectionHeader>Library Per Day</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
@@ -239,21 +239,21 @@ export function TrendsTab() {
})
}
/>
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart title="Videos per Title" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Title (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart
title="Cards Mined per Anime"
title="Cards Mined per Title"
data={filteredCardsPerAnime}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
<StackedTrendChart title="Words Seen per Title" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Title" data={filteredLookupsPerAnime} />
<StackedTrendChart
title="Lookups/100w per Anime"
title="Lookups/100w per Title"
data={filteredLookupsPerHundredPerAnime}
/>
<SectionHeader>Anime Cumulative</SectionHeader>
<SectionHeader>Library Cumulative</SectionHeader>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart
@@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({
>
{'\u25B6'}
</span>
Words In Multiple Anime
Words Across Multiple Titles
</button>
<div className="flex items-center gap-3">
{hasKnownData && (
@@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown
? 'All multi-anime words are already known!'
: 'No words found across multiple anime.'}
? 'All words that span multiple titles are already known!'
: 'No words found across multiple titles.'}
</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">Reading</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>
</tr>
</thead>
@@ -0,0 +1,40 @@
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 reading in brackets 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">
<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">Reading</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>
</tr>
@@ -128,9 +127,17 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()}
</td>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{(() => {
const reading = fullReading(w.headword, w.reading);
if (!reading || reading === w.headword) return null;
return (
<span className="text-ctp-subtext0 text-xs ml-1.5">
{reading}
</span>
);
})()}
</td>
<td className="py-1.5 pr-3">
{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 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 function useTrends(range: TimeRange, groupBy: GroupBy) {
+49
View File
@@ -115,6 +115,55 @@ 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: [] },
animePerDay: {
episodes: [],
watchTime: [],
cards: [],
words: [],
lookups: [],
lookupsPerHundred: [],
},
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 () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
+1 -1
View File
@@ -116,7 +116,7 @@ export const apiClient = {
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) =>
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>(
`/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',
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,
};
+36
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
confirmBucketDelete,
confirmDayGroupDelete,
confirmEpisodeDelete,
confirmSessionDelete,
@@ -54,6 +55,41 @@ 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.equal(calls.length, 1);
assert.match(calls[0]!, /3/);
assert.match(calls[0]!, /My Episode/);
assert.match(calls[0]!, /sessions/);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmBucketDelete uses singular 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.match(calls[0]!, /1 session of/);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
+6
View File
@@ -17,3 +17,9 @@ export function confirmAnimeGroupDelete(title: string, count: number): boolean {
export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
}
export function confirmBucketDelete(title: string, count: number): boolean {
return globalThis.confirm(
`Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`,
);
}
+72
View File
@@ -0,0 +1,72 @@
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;
}