Compare commits

...

15 Commits

Author SHA1 Message Date
sudacode 0942e8fd45 Rename anime visibility heading to title visibility
- Update trends filter heading text
- Add regression test for rendered label
2026-04-10 02:10:24 -07:00
sudacode 233591435d refactor(stats): reorder trends sections and move library summary to end
Move pattern graphs (day-of-week, hour) into the Activity section,
promote Library — Cumulative above Library — Summary, and place the
summary table last since it is data rather than a trend visualization.
2026-04-10 02:02:45 -07:00
sudacode 7e355dbac6 style(stats): fix prettier formatting in LibrarySummarySection 2026-04-09 22:33:16 -07:00
sudacode 73c33930a9 docs(changelog): summarize library summary replacing per-day trends 2026-04-09 22:26:02 -07:00
sudacode 0294671de1 feat(stats): replace per-day trends section with library summary 2026-04-09 22:23:58 -07:00
sudacode 8d45102848 feat(stats): add LibrarySummarySection with leaderboard chart and sortable table 2026-04-09 22:21:32 -07:00
sudacode 51b38f615d refactor(stats): replace animePerDay type with librarySummary 2026-04-09 22:19:29 -07:00
sudacode 8751ffd6c8 refactor(stats): drop animePerDay from trends response in favor of librarySummary 2026-04-09 22:16:17 -07:00
sudacode f91c600ed0 test(stats): cover librarySummary null-lookups and empty-window cases 2026-04-09 22:08:11 -07:00
sudacode 6977c59691 feat(stats): build per-title librarySummary from daily rollups and sessions 2026-04-09 21:58:15 -07:00
sudacode 8e77e422e8 feat(stats): scaffold LibrarySummaryRow type and empty field 2026-04-09 21:53:19 -07:00
sudacode 928a0d6b61 docs(plans): add implementation plan for library summary replaces per-day
Also corrects the spec's chart library reference (Recharts, not
ECharts) and clarifies that the backend keeps the internal
animePerDay computation as an intermediate for animeCumulative,
only dropping it from the API response.
2026-04-09 21:51:02 -07:00
sudacode 70d52248f8 fix(stats): address CodeRabbit review on PR #50
- Guard episode deletion against double-submit with an isDeletingRef +
  setIsDeleting pair threaded through buildDeleteEpisodeHandler, and
  disable the MediaHeader delete button while a request is in flight.
- Restore MediaHeader title truncation by adding min-w-0 flex-1 to the
  h2 so long titles shrink instead of pushing the delete button away.
- Normalize the headword in FrequencyRankTable before comparing it to
  the (hiragana-normalized) reading so katakana-only entries like カレー
  no longer render a redundant 【かれー】. Test strengthened to reject
  any bracketed reading, not just the literal.
- Rewrite confirmBucketDelete copy to include the "and all associated
  data" warning and handle singular/plural cleanly.
- Run Prettier across the stats files CI was complaining about
  (EpisodeDetail, WatchTimeChart, SessionsTab + test, FrequencyRankTable
  + test, session-grouping test) to clear the format:check:stats gate.
2026-04-09 21:48:43 -07:00
sudacode f4c7923f2b docs(specs): add library summary replaces per-day design
Replaces the six noisy per-day stacked-area charts in the stats
Trends tab with a single "Library — Summary" section: a top-10
watch-time leaderboard plus a sortable per-title table. Scoped
to the existing date range selector; integrates with the shared
visibility filter.
2026-04-09 21:43:47 -07:00
sudacode 42cc35dcd6 feat(stats): drop seek markers from session timeline
Seek-forward / seek-backward events cluttered sessions with lots of
rewinds — a single episode could show dozens of << and >> icons
overlapping the card and pause markers. Stop requesting them from the
backend, drop them from buildSessionChartEvents, remove the seek
variant from SessionChartMarker, and strip the matching ReferenceLines,
overlay marker, popover branch, and legend entry from SessionDetail.
2026-04-09 21:28:20 -07:00
31 changed files with 2304 additions and 346 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 -->
+1
View File
@@ -8,3 +8,4 @@ area: stats
- 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
@@ -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.
@@ -166,14 +166,20 @@ const TRENDS_DASHBOARD = {
ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
},
animePerDay: {
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
},
librarySummary: [
{
title: 'Little Witch Academia',
watchTimeMin: 25,
videos: 1,
sessions: 1,
cards: 5,
words: 300,
lookups: 15,
lookupsPerHundred: 5,
firstWatched: 20_000,
lastWatched: 20_000,
},
],
animeCumulative: {
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
@@ -598,7 +604,7 @@ describe('stats server API routes', () => {
const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']);
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 () => {
@@ -687,7 +687,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18);
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.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
@@ -3725,3 +3725,224 @@ test('deleteSession removes zero-session media from library and trends', () => {
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);
}
});
@@ -27,6 +27,19 @@ interface TrendPerAnimePoint {
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 {
startedAtMs: number;
epochDay: number;
@@ -61,14 +74,6 @@ export interface TrendsDashboardQueryResult {
ratios: {
lookupsPerHundred: TrendChartPoint[];
};
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
animeCumulative: {
watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[];
@@ -79,6 +84,7 @@ export interface TrendsDashboardQueryResult {
watchTimeByDayOfWeek: TrendChartPoint[];
watchTimeByHour: TrendChartPoint[];
};
librarySummary: LibrarySummaryRow[];
}
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
@@ -301,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[] {
const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>();
@@ -391,6 +342,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
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(
db: DatabaseSync,
videoIds: Array<number | null>,
@@ -663,8 +697,6 @@ export function getTrendsDashboard(
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
};
return {
@@ -691,7 +723,6 @@ export function getTrendsDashboard(
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
animePerDay,
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
@@ -702,5 +733,6 @@ export function getTrendsDashboard(
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
};
}
+2 -1
View File
@@ -184,7 +184,8 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</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)
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from
Anki)
</div>
)}
</div>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
@@ -14,17 +14,31 @@ interface DeleteEpisodeHandlerOptions {
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);
}
};
}
@@ -57,6 +71,8 @@ export function MediaDetailView({
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
const isDeletingEpisodeRef = useRef(false);
useEffect(() => {
setLocalSessions(data?.sessions ?? null);
@@ -108,6 +124,8 @@ export function MediaDetailView({
confirmFn: confirmEpisodeDelete,
onBack,
setDeleteError,
isDeletingRef: isDeletingEpisodeRef,
setIsDeleting: setIsDeletingEpisode,
});
return (
@@ -130,7 +148,11 @@ export function MediaDetailView({
</button>
) : null}
</div>
<MediaHeader detail={detail} onDeleteEpisode={handleDeleteEpisode} />
<MediaHeader
detail={detail}
onDeleteEpisode={handleDeleteEpisode}
isDeletingEpisode={isDeletingEpisode}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList
sessions={sessions}
+8 -3
View File
@@ -13,12 +13,14 @@ interface MediaHeaderProps {
knownWordCount: number;
} | null;
onDeleteEpisode?: () => void;
isDeletingEpisode?: boolean;
}
export function MediaHeader({
detail,
initialKnownWordsSummary = null,
onDeleteEpisode,
isDeletingEpisode = false,
}: MediaHeaderProps) {
const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
@@ -56,14 +58,17 @@ export function MediaHeader({
/>
<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>
<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}
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity"
disabled={isDeletingEpisode}
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete Episode
{isDeletingEpisode ? 'Deleting...' : 'Delete Episode'}
</button>
) : null}
</div>
@@ -1,13 +1,5 @@
import { useState } from 'react';
import {
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import type { DailyRollup } from '../../types/stats';
@@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) {
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
buildSessionChartEvents(events);
const lookupRate = buildLookupRateDisplay(
session.yomitanLookupCount,
getSessionDisplayWordCount(session),
);
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length;
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
const activeMarker = useMemo<SessionChartMarker | null>(
@@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
@@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
@@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
@@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
@@ -280,7 +275,6 @@ function RatioView({
sorted,
knownWordsMap,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
@@ -292,7 +286,6 @@ function RatioView({
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
@@ -300,7 +293,6 @@ function RatioView({
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
@@ -312,7 +304,6 @@ function RatioView({
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
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 */}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
@@ -549,7 +524,6 @@ function RatioView({
<StatsBar
hasKnownWords
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
@@ -563,7 +537,6 @@ function RatioView({
function FallbackView({
sorted,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
@@ -575,14 +548,12 @@ function FallbackView({
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
@@ -594,7 +565,6 @@ function FallbackView({
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
@@ -680,20 +650,6 @@ function FallbackView({
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) => (
<ReferenceLine
key={`yomitan-${i}`}
@@ -735,7 +691,6 @@ function FallbackView({
<StatsBar
hasKnownWords={false}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
@@ -749,14 +704,12 @@ function FallbackView({
function StatsBar({
hasKnownWords,
pauseCount,
seekCount,
cardEventCount,
session,
lookupRate,
}: {
hasKnownWords: boolean;
pauseCount: number;
seekCount: number;
cardEventCount: number;
session: SessionSummary;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
@@ -791,12 +744,7 @@ function StatsBar({
{pauseCount !== 1 ? 's' : ''}
</span>
)}
{seekCount > 0 && (
<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>}
{pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
{/* Group 3: Learning events */}
<span className="flex items-center gap-1.5">
@@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'seek':
return marker.direction === 'backward' ? '<<' : '>>';
case 'card':
return '\u26CF';
}
@@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
switch (marker.kind) {
case 'pause':
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':
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/);
});
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', () => {
const marker: SessionChartMarker = {
key: 'card-9000',
@@ -31,18 +31,12 @@ export function SessionEventPopover({
onClose,
onOpenNote,
}: SessionEventPopoverProps) {
const seekDurationLabel =
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
: null;
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="mb-2 flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'}
{marker.kind === 'seek' && `Seek ${marker.direction}`}
{marker.kind === 'card' && 'Card mined'}
</div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
@@ -72,7 +66,6 @@ export function SessionEventPopover({
) : null}
<div className="text-sm">
{marker.kind === 'pause' && '||'}
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
{marker.kind === 'card' && '\u26CF'}
</div>
</div>
@@ -84,19 +77,6 @@ export function SessionEventPopover({
</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' && (
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">
@@ -75,10 +75,7 @@ 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 bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
@@ -104,10 +101,7 @@ test('buildBucketDeleteHandler reports errors via onError without calling onSucc
let errorMessage: string | null = null;
let successCalled = false;
const bucket = makeBucket([
makeSession({ sessionId: 1 }),
makeSession({ sessionId: 2 }),
]);
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
@@ -269,9 +269,7 @@ export function SessionsTab({
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(
expandedId === s.sessionId ? null : s.sessionId,
)
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
@@ -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>
</>
);
}
@@ -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,
pruneHiddenAnime,
} from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection';
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
@@ -28,7 +29,7 @@ interface AnimeVisibilityFilterProps {
onToggleAnime: (title: string) => void;
}
function AnimeVisibilityFilter({
export function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
onShowAll,
@@ -44,7 +45,7 @@ function AnimeVisibilityFilter({
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
Anime Visibility
Title Visibility
</h4>
<p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything.
@@ -114,11 +115,6 @@ export function TrendsTab() {
if (!data) return null;
const animeTitles = buildAnimeVisibilityOptions([
data.animePerDay.episodes,
data.animePerDay.watchTime,
data.animePerDay.cards,
data.animePerDay.words,
data.animePerDay.lookups,
data.animeCumulative.episodes,
data.animeCumulative.cards,
data.animeCumulative.words,
@@ -126,24 +122,6 @@ export function TrendsTab() {
]);
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(
data.animeCumulative.episodes,
activeHiddenAnime,
@@ -185,6 +163,18 @@ export function TrendsTab() {
/>
<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="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>
<TrendChart
@@ -221,7 +211,7 @@ export function TrendsTab() {
type="line"
/>
<SectionHeader>Library Per Day</SectionHeader>
<SectionHeader>Library Cumulative</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
@@ -239,21 +229,6 @@ export function TrendsTab() {
})
}
/>
<StackedTrendChart title="Videos per Title" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Title (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart
title="Cards Mined per Title"
data={filteredCardsPerAnime}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Words Seen per Title" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Title" data={filteredLookupsPerAnime} />
<StackedTrendChart
title="Lookups/100w per Title"
data={filteredLookupsPerHundredPerAnime}
/>
<SectionHeader>Library Cumulative</SectionHeader>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart
@@ -263,19 +238,8 @@ export function TrendsTab() {
/>
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader>
<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>Library Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
</div>
</div>
);
@@ -36,5 +36,8 @@ test('omits reading when reading equals headword', () => {
<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');
assert.ok(
!markup.includes('【'),
'should not render any bracketed reading when equal to headword',
);
});
@@ -131,11 +131,13 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<span className="text-ctp-text font-medium">{w.headword}</span>
{(() => {
const reading = fullReading(w.headword, w.reading);
if (!reading || reading === w.headword) return null;
// `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>
<span className="text-ctp-subtext0 text-xs ml-1.5">{reading}</span>
);
})()}
</td>
+2 -16
View File
@@ -84,14 +84,7 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
lookups: [],
},
ratios: { lookupsPerHundred: [] },
animePerDay: {
episodes: [],
watchTime: [],
cards: [],
words: [],
lookups: [],
lookupsPerHundred: [],
},
librarySummary: [],
animeCumulative: {
watchTime: [],
episodes: [],
@@ -133,14 +126,7 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
lookups: [],
},
ratios: { lookupsPerHundred: [] },
animePerDay: {
episodes: [],
watchTime: [],
cards: [],
words: [],
lookups: [],
lookupsPerHundred: [],
},
librarySummary: [],
animeCumulative: {
watchTime: [],
episodes: [],
+7 -6
View File
@@ -65,16 +65,15 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
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/);
assert.deepEqual(calls, [
'Delete all 3 sessions of "My Episode" from this day and all associated data?',
]);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmBucketDelete uses singular for one session', () => {
test('confirmBucketDelete uses a clean singular form for one session', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
@@ -84,7 +83,9 @@ test('confirmBucketDelete uses singular for one session', () => {
try {
assert.equal(confirmBucketDelete('Solo Episode', 1), false);
assert.match(calls[0]!, /1 session of/);
assert.deepEqual(calls, [
'Delete this session of "Solo Episode" from this day and all associated data?',
]);
} finally {
globalThis.confirm = originalConfirm;
}
+6 -1
View File
@@ -19,7 +19,12 @@ export function confirmEpisodeDelete(title: string): boolean {
}
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} session${count === 1 ? '' : 's'} of "${title}" from this day?`,
`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}' },
]);
// Seek events are intentionally dropped from the chart — they were too noisy.
assert.deepEqual(
chartEvents.seekEvents.map((event) => event.eventType),
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
[],
);
assert.deepEqual(
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 },
]);
// Seek events are intentionally dropped — too noisy on the session chart.
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
['seek', 'pause', 'card'],
['pause', 'card'],
);
const seekMarker = 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]!;
const pauseMarker = chartEvents.markers[0]!;
assert.equal(pauseMarker.kind, 'pause');
assert.equal(pauseMarker.startMs, 2_000);
assert.equal(pauseMarker.endMs, 5_000);
assert.equal(pauseMarker.durationMs, 3_000);
assert.equal(pauseMarker.anchorTsMs, 3_500);
const cardMarker = chartEvents.markers[2]!;
const cardMarker = chartEvents.markers[1]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
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 = [
EventType.CARD_MINED,
EventType.SEEK_FORWARD,
EventType.SEEK_BACKWARD,
EventType.PAUSE_START,
EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP,
@@ -16,7 +14,6 @@ export interface PauseRegion {
export interface SessionChartEvents {
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
@@ -58,15 +55,6 @@ export type SessionChartMarker =
endMs: number;
durationMs: number;
}
| {
key: string;
kind: 'seek';
anchorTsMs: number;
eventTsMs: number;
direction: 'forward' | 'backward';
fromMs: number | null;
toMs: number | null;
}
| {
key: string;
kind: 'card';
@@ -295,7 +283,6 @@ export function projectSessionMarkerLeftPx({
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const seekEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = [];
const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = [];
@@ -317,22 +304,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
});
}
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:
yomitanLookupEvents.push(event);
break;
@@ -376,7 +347,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
return {
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
+28 -4
View File
@@ -32,8 +32,20 @@ test('empty input returns empty array', () => {
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 }),
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);
@@ -45,8 +57,20 @@ test('two unique videoIds produce 2 singleton buckets', () => {
});
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 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;
+14 -8
View File
@@ -288,6 +288,19 @@ export interface TrendPerAnimePoint {
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 {
activity: {
watchTime: TrendChartPoint[];
@@ -307,14 +320,7 @@ export interface TrendsDashboardData {
ratios: {
lookupsPerHundred: TrendChartPoint[];
};
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
librarySummary: LibrarySummaryRow[];
animeCumulative: {
watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[];