mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-28 12:55:17 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0942e8fd45
|
|||
|
233591435d
|
|||
|
7e355dbac6
|
|||
|
73c33930a9
|
|||
|
0294671de1
|
|||
|
8d45102848
|
|||
|
51b38f615d
|
|||
|
8751ffd6c8
|
|||
|
f91c600ed0
|
|||
|
6977c59691
|
|||
|
8e77e422e8
|
|||
|
928a0d6b61
|
|||
|
70d52248f8
|
|||
|
f4c7923f2b
|
|||
|
42cc35dcd6
|
+34
@@ -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 -->
|
||||||
@@ -8,3 +8,4 @@ area: stats
|
|||||||
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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: {
|
ratios: {
|
||||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||||
},
|
},
|
||||||
animePerDay: {
|
librarySummary: [
|
||||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
{
|
||||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
title: 'Little Witch Academia',
|
||||||
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
watchTimeMin: 25,
|
||||||
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
|
videos: 1,
|
||||||
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
|
sessions: 1,
|
||||||
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
cards: 5,
|
||||||
},
|
words: 300,
|
||||||
|
lookups: 15,
|
||||||
|
lookupsPerHundred: 5,
|
||||||
|
firstWatched: 20_000,
|
||||||
|
lastWatched: 20_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
||||||
@@ -598,7 +604,7 @@ describe('stats server API routes', () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
assert.deepEqual(seenArgs, ['90d', 'month']);
|
assert.deepEqual(seenArgs, ['90d', 'month']);
|
||||||
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
||||||
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
|
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/trends/dashboard accepts 365d range', async () => {
|
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.watchTime[1]?.value, 75);
|
||||||
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
||||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
||||||
assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
|
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
||||||
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
||||||
@@ -3725,3 +3725,224 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', {
|
||||||
|
canonicalTitle: 'Library Summary Test',
|
||||||
|
sourcePath: '/tmp/library-summary-test.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Summary Anime',
|
||||||
|
canonicalTitle: 'Summary Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'library-summary-test.mkv',
|
||||||
|
parsedTitle: 'Summary Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'test',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayOneStart = 1_700_000_000_000;
|
||||||
|
const dayTwoStart = dayOneStart + 86_400_000;
|
||||||
|
|
||||||
|
const sessionOne = startSessionRecord(db, videoId, dayOneStart);
|
||||||
|
const sessionTwo = startSessionRecord(db, videoId, dayTwoStart);
|
||||||
|
|
||||||
|
for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [
|
||||||
|
[sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8],
|
||||||
|
[sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10],
|
||||||
|
] as const) {
|
||||||
|
stmts.telemetryInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
`${startedAtMs + 60_000}`,
|
||||||
|
activeMs,
|
||||||
|
activeMs,
|
||||||
|
10,
|
||||||
|
tokens,
|
||||||
|
cards,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
lookups,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
`${startedAtMs + 60_000}`,
|
||||||
|
`${startedAtMs + 60_000}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions
|
||||||
|
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
|
||||||
|
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
`${startedAtMs + activeMs}`,
|
||||||
|
activeMs,
|
||||||
|
activeMs,
|
||||||
|
10,
|
||||||
|
tokens,
|
||||||
|
cards,
|
||||||
|
lookups,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [day, active, tokens, cards] of [
|
||||||
|
[Math.floor(dayOneStart / 86_400_000), 30, 120, 2],
|
||||||
|
[Math.floor(dayTwoStart / 86_400_000), 45, 140, 3],
|
||||||
|
] as const) {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(day, videoId, 1, active, 10, tokens, cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
|
|
||||||
|
assert.equal(dashboard.librarySummary.length, 1);
|
||||||
|
const row = dashboard.librarySummary[0]!;
|
||||||
|
assert.equal(row.title, 'Summary Anime');
|
||||||
|
assert.equal(row.watchTimeMin, 75);
|
||||||
|
assert.equal(row.videos, 1);
|
||||||
|
assert.equal(row.sessions, 2);
|
||||||
|
assert.equal(row.cards, 5);
|
||||||
|
assert.equal(row.words, 260);
|
||||||
|
assert.equal(row.lookups, 18);
|
||||||
|
assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1));
|
||||||
|
assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000));
|
||||||
|
assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lib-summary-null.mkv', {
|
||||||
|
canonicalTitle: 'Null Lookups Title',
|
||||||
|
sourcePath: '/tmp/lib-summary-null.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Null Lookups Anime',
|
||||||
|
canonicalTitle: 'Null Lookups Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'lib-summary-null.mkv',
|
||||||
|
parsedTitle: 'Null Lookups Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'test',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMs = 1_700_000_000_000;
|
||||||
|
const session = startSessionRecord(db, videoId, startMs);
|
||||||
|
stmts.telemetryInsertStmt.run(
|
||||||
|
session.sessionId,
|
||||||
|
`${startMs + 60_000}`,
|
||||||
|
20 * 60_000,
|
||||||
|
20 * 60_000,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
`${startMs + 60_000}`,
|
||||||
|
`${startMs + 60_000}`,
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions
|
||||||
|
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
|
||||||
|
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
`${startMs + 20 * 60_000}`,
|
||||||
|
20 * 60_000,
|
||||||
|
20 * 60_000,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
session.sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(Math.floor(startMs / 86_400_000), videoId, 1, 20, 5, 0, 0);
|
||||||
|
|
||||||
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
|
assert.equal(dashboard.librarySummary.length, 1);
|
||||||
|
assert.equal(dashboard.librarySummary[0]!.lookupsPerHundred, null);
|
||||||
|
assert.equal(dashboard.librarySummary[0]!.words, 0);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
|
assert.deepEqual(dashboard.librarySummary, []);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ interface TrendPerAnimePoint {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibrarySummaryRow {
|
||||||
|
title: string;
|
||||||
|
watchTimeMin: number;
|
||||||
|
videos: number;
|
||||||
|
sessions: number;
|
||||||
|
cards: number;
|
||||||
|
words: number;
|
||||||
|
lookups: number;
|
||||||
|
lookupsPerHundred: number | null;
|
||||||
|
firstWatched: number;
|
||||||
|
lastWatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface TrendSessionMetricRow {
|
interface TrendSessionMetricRow {
|
||||||
startedAtMs: number;
|
startedAtMs: number;
|
||||||
epochDay: number;
|
epochDay: number;
|
||||||
@@ -61,14 +74,6 @@ export interface TrendsDashboardQueryResult {
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: TrendChartPoint[];
|
lookupsPerHundred: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
animePerDay: {
|
|
||||||
episodes: TrendPerAnimePoint[];
|
|
||||||
watchTime: TrendPerAnimePoint[];
|
|
||||||
cards: TrendPerAnimePoint[];
|
|
||||||
words: TrendPerAnimePoint[];
|
|
||||||
lookups: TrendPerAnimePoint[];
|
|
||||||
lookupsPerHundred: TrendPerAnimePoint[];
|
|
||||||
};
|
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: TrendPerAnimePoint[];
|
watchTime: TrendPerAnimePoint[];
|
||||||
episodes: TrendPerAnimePoint[];
|
episodes: TrendPerAnimePoint[];
|
||||||
@@ -79,6 +84,7 @@ export interface TrendsDashboardQueryResult {
|
|||||||
watchTimeByDayOfWeek: TrendChartPoint[];
|
watchTimeByDayOfWeek: TrendChartPoint[];
|
||||||
watchTimeByHour: TrendChartPoint[];
|
watchTimeByHour: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
|
librarySummary: LibrarySummaryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
||||||
@@ -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[] {
|
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||||
const byAnime = new Map<string, Map<number, number>>();
|
const byAnime = new Map<string, Map<number, number>>();
|
||||||
const allDays = new Set<number>();
|
const allDays = new Set<number>();
|
||||||
@@ -391,6 +342,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLibrarySummary(
|
||||||
|
rollups: ImmersionSessionRollupRow[],
|
||||||
|
sessions: TrendSessionMetricRow[],
|
||||||
|
titlesByVideoId: Map<number, string>,
|
||||||
|
): LibrarySummaryRow[] {
|
||||||
|
type Accum = {
|
||||||
|
watchTimeMin: number;
|
||||||
|
videos: Set<number>;
|
||||||
|
cards: number;
|
||||||
|
words: number;
|
||||||
|
firstWatched: number;
|
||||||
|
lastWatched: number;
|
||||||
|
sessions: number;
|
||||||
|
lookups: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const byTitle = new Map<string, Accum>();
|
||||||
|
|
||||||
|
const ensure = (title: string): Accum => {
|
||||||
|
const existing = byTitle.get(title);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created: Accum = {
|
||||||
|
watchTimeMin: 0,
|
||||||
|
videos: new Set<number>(),
|
||||||
|
cards: 0,
|
||||||
|
words: 0,
|
||||||
|
firstWatched: Number.POSITIVE_INFINITY,
|
||||||
|
lastWatched: Number.NEGATIVE_INFINITY,
|
||||||
|
sessions: 0,
|
||||||
|
lookups: 0,
|
||||||
|
};
|
||||||
|
byTitle.set(title, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rollup of rollups) {
|
||||||
|
if (rollup.videoId === null) continue;
|
||||||
|
const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
|
||||||
|
const acc = ensure(title);
|
||||||
|
acc.watchTimeMin += rollup.totalActiveMin;
|
||||||
|
acc.cards += rollup.totalCards;
|
||||||
|
acc.words += rollup.totalTokensSeen;
|
||||||
|
acc.videos.add(rollup.videoId);
|
||||||
|
if (rollup.rollupDayOrMonth < acc.firstWatched) {
|
||||||
|
acc.firstWatched = rollup.rollupDayOrMonth;
|
||||||
|
}
|
||||||
|
if (rollup.rollupDayOrMonth > acc.lastWatched) {
|
||||||
|
acc.lastWatched = rollup.rollupDayOrMonth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const title = resolveTrendAnimeTitle(session);
|
||||||
|
if (!byTitle.has(title)) continue;
|
||||||
|
const acc = byTitle.get(title)!;
|
||||||
|
acc.sessions += 1;
|
||||||
|
acc.lookups += session.yomitanLookupCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: LibrarySummaryRow[] = [];
|
||||||
|
for (const [title, acc] of byTitle) {
|
||||||
|
if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
title,
|
||||||
|
watchTimeMin: Math.round(acc.watchTimeMin),
|
||||||
|
videos: acc.videos.size,
|
||||||
|
sessions: acc.sessions,
|
||||||
|
cards: acc.cards,
|
||||||
|
words: acc.words,
|
||||||
|
lookups: acc.lookups,
|
||||||
|
lookupsPerHundred:
|
||||||
|
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
|
||||||
|
firstWatched: acc.firstWatched,
|
||||||
|
lastWatched: acc.lastWatched,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
function getVideoAnimeTitleMap(
|
function getVideoAnimeTitleMap(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoIds: Array<number | null>,
|
videoIds: Array<number | null>,
|
||||||
@@ -663,8 +697,6 @@ export function getTrendsDashboard(
|
|||||||
titlesByVideoId,
|
titlesByVideoId,
|
||||||
(rollup) => rollup.totalTokensSeen,
|
(rollup) => rollup.totalTokensSeen,
|
||||||
),
|
),
|
||||||
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
|
|
||||||
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -691,7 +723,6 @@ export function getTrendsDashboard(
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||||
},
|
},
|
||||||
animePerDay,
|
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||||
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
||||||
@@ -702,5 +733,6 @@ export function getTrendsDashboard(
|
|||||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||||
},
|
},
|
||||||
|
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
{hiddenCardCount > 0 && (
|
{hiddenCardCount > 0 && (
|
||||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
|
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||||
@@ -14,17 +14,31 @@ interface DeleteEpisodeHandlerOptions {
|
|||||||
confirmFn: (title: string) => boolean;
|
confirmFn: (title: string) => boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
setDeleteError: (msg: string | null) => 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> {
|
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||||
return async () => {
|
return async () => {
|
||||||
|
if (opts.isDeletingRef?.current) return;
|
||||||
if (!opts.confirmFn(opts.title)) return;
|
if (!opts.confirmFn(opts.title)) return;
|
||||||
|
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||||
|
opts.setIsDeleting?.(true);
|
||||||
opts.setDeleteError(null);
|
opts.setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await opts.apiClient.deleteVideo(opts.videoId);
|
await opts.apiClient.deleteVideo(opts.videoId);
|
||||||
opts.onBack();
|
opts.onBack();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.');
|
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 [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||||
|
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
||||||
|
const isDeletingEpisodeRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalSessions(data?.sessions ?? null);
|
setLocalSessions(data?.sessions ?? null);
|
||||||
@@ -108,6 +124,8 @@ export function MediaDetailView({
|
|||||||
confirmFn: confirmEpisodeDelete,
|
confirmFn: confirmEpisodeDelete,
|
||||||
onBack,
|
onBack,
|
||||||
setDeleteError,
|
setDeleteError,
|
||||||
|
isDeletingRef: isDeletingEpisodeRef,
|
||||||
|
setIsDeleting: setIsDeletingEpisode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,7 +148,11 @@ export function MediaDetailView({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<MediaHeader detail={detail} onDeleteEpisode={handleDeleteEpisode} />
|
<MediaHeader
|
||||||
|
detail={detail}
|
||||||
|
onDeleteEpisode={handleDeleteEpisode}
|
||||||
|
isDeletingEpisode={isDeletingEpisode}
|
||||||
|
/>
|
||||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||||
<MediaSessionList
|
<MediaSessionList
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ interface MediaHeaderProps {
|
|||||||
knownWordCount: number;
|
knownWordCount: number;
|
||||||
} | null;
|
} | null;
|
||||||
onDeleteEpisode?: () => void;
|
onDeleteEpisode?: () => void;
|
||||||
|
isDeletingEpisode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaHeader({
|
export function MediaHeader({
|
||||||
detail,
|
detail,
|
||||||
initialKnownWordsSummary = null,
|
initialKnownWordsSummary = null,
|
||||||
onDeleteEpisode,
|
onDeleteEpisode,
|
||||||
|
isDeletingEpisode = false,
|
||||||
}: MediaHeaderProps) {
|
}: MediaHeaderProps) {
|
||||||
const knownTokenRate =
|
const knownTokenRate =
|
||||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
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-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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 ? (
|
{onDeleteEpisode != null ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDeleteEpisode}
|
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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
CartesianGrid,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import { epochDayToDate } from '../../lib/formatters';
|
import { epochDayToDate } from '../../lib/formatters';
|
||||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||||
import type { DailyRollup } from '../../types/stats';
|
import type { DailyRollup } from '../../types/stats';
|
||||||
|
|||||||
@@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||||
const hasKnownWords = knownWordsMap.size > 0;
|
const hasKnownWords = knownWordsMap.size > 0;
|
||||||
|
|
||||||
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
|
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||||
buildSessionChartEvents(events);
|
buildSessionChartEvents(events);
|
||||||
const lookupRate = buildLookupRateDisplay(
|
const lookupRate = buildLookupRateDisplay(
|
||||||
session.yomitanLookupCount,
|
session.yomitanLookupCount,
|
||||||
getSessionDisplayWordCount(session),
|
getSessionDisplayWordCount(session),
|
||||||
);
|
);
|
||||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||||
const seekCount = seekEvents.length;
|
|
||||||
const cardEventCount = cardEvents.length;
|
const cardEventCount = cardEvents.length;
|
||||||
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
||||||
const activeMarker = useMemo<SessionChartMarker | null>(
|
const activeMarker = useMemo<SessionChartMarker | null>(
|
||||||
@@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
sorted={sorted}
|
sorted={sorted}
|
||||||
knownWordsMap={knownWordsMap}
|
knownWordsMap={knownWordsMap}
|
||||||
cardEvents={cardEvents}
|
cardEvents={cardEvents}
|
||||||
seekEvents={seekEvents}
|
|
||||||
yomitanLookupEvents={yomitanLookupEvents}
|
yomitanLookupEvents={yomitanLookupEvents}
|
||||||
pauseRegions={pauseRegions}
|
pauseRegions={pauseRegions}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
@@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
loadingNoteIds={loadingNoteIds}
|
loadingNoteIds={loadingNoteIds}
|
||||||
onOpenNote={handleOpenNote}
|
onOpenNote={handleOpenNote}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
seekCount={seekCount}
|
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
session={session}
|
session={session}
|
||||||
@@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
<FallbackView
|
<FallbackView
|
||||||
sorted={sorted}
|
sorted={sorted}
|
||||||
cardEvents={cardEvents}
|
cardEvents={cardEvents}
|
||||||
seekEvents={seekEvents}
|
|
||||||
yomitanLookupEvents={yomitanLookupEvents}
|
yomitanLookupEvents={yomitanLookupEvents}
|
||||||
pauseRegions={pauseRegions}
|
pauseRegions={pauseRegions}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
@@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
loadingNoteIds={loadingNoteIds}
|
loadingNoteIds={loadingNoteIds}
|
||||||
onOpenNote={handleOpenNote}
|
onOpenNote={handleOpenNote}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
seekCount={seekCount}
|
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
session={session}
|
session={session}
|
||||||
@@ -280,7 +275,6 @@ function RatioView({
|
|||||||
sorted,
|
sorted,
|
||||||
knownWordsMap,
|
knownWordsMap,
|
||||||
cardEvents,
|
cardEvents,
|
||||||
seekEvents,
|
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
markers,
|
markers,
|
||||||
@@ -292,7 +286,6 @@ function RatioView({
|
|||||||
loadingNoteIds,
|
loadingNoteIds,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
seekCount,
|
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
lookupRate,
|
lookupRate,
|
||||||
session,
|
session,
|
||||||
@@ -300,7 +293,6 @@ function RatioView({
|
|||||||
sorted: TimelineEntry[];
|
sorted: TimelineEntry[];
|
||||||
knownWordsMap: Map<number, number>;
|
knownWordsMap: Map<number, number>;
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
seekEvents: SessionEvent[];
|
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
@@ -312,7 +304,6 @@ function RatioView({
|
|||||||
loadingNoteIds: Set<number>;
|
loadingNoteIds: Set<number>;
|
||||||
onOpenNote: (noteId: number) => void;
|
onOpenNote: (noteId: number) => void;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
seekCount: number;
|
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
@@ -450,22 +441,6 @@ function RatioView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{seekEvents.map((e, i) => {
|
|
||||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
|
||||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
|
||||||
return (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`seek-${i}`}
|
|
||||||
yAxisId="pct"
|
|
||||||
x={e.tsMs}
|
|
||||||
stroke={stroke}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeOpacity={0.75}
|
|
||||||
strokeDasharray="4 3"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Yomitan lookup markers */}
|
{/* Yomitan lookup markers */}
|
||||||
{yomitanLookupEvents.map((e, i) => (
|
{yomitanLookupEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
@@ -549,7 +524,6 @@ function RatioView({
|
|||||||
<StatsBar
|
<StatsBar
|
||||||
hasKnownWords
|
hasKnownWords
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
seekCount={seekCount}
|
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
session={session}
|
session={session}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
@@ -563,7 +537,6 @@ function RatioView({
|
|||||||
function FallbackView({
|
function FallbackView({
|
||||||
sorted,
|
sorted,
|
||||||
cardEvents,
|
cardEvents,
|
||||||
seekEvents,
|
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
markers,
|
markers,
|
||||||
@@ -575,14 +548,12 @@ function FallbackView({
|
|||||||
loadingNoteIds,
|
loadingNoteIds,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
seekCount,
|
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
lookupRate,
|
lookupRate,
|
||||||
session,
|
session,
|
||||||
}: {
|
}: {
|
||||||
sorted: TimelineEntry[];
|
sorted: TimelineEntry[];
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
seekEvents: SessionEvent[];
|
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
@@ -594,7 +565,6 @@ function FallbackView({
|
|||||||
loadingNoteIds: Set<number>;
|
loadingNoteIds: Set<number>;
|
||||||
onOpenNote: (noteId: number) => void;
|
onOpenNote: (noteId: number) => void;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
seekCount: number;
|
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
@@ -680,20 +650,6 @@ function FallbackView({
|
|||||||
strokeOpacity={0.8}
|
strokeOpacity={0.8}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{seekEvents.map((e, i) => {
|
|
||||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
|
||||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
|
||||||
return (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`seek-${i}`}
|
|
||||||
x={e.tsMs}
|
|
||||||
stroke={stroke}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeOpacity={0.75}
|
|
||||||
strokeDasharray="4 3"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{yomitanLookupEvents.map((e, i) => (
|
{yomitanLookupEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`yomitan-${i}`}
|
key={`yomitan-${i}`}
|
||||||
@@ -735,7 +691,6 @@ function FallbackView({
|
|||||||
<StatsBar
|
<StatsBar
|
||||||
hasKnownWords={false}
|
hasKnownWords={false}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
seekCount={seekCount}
|
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
session={session}
|
session={session}
|
||||||
lookupRate={lookupRate}
|
lookupRate={lookupRate}
|
||||||
@@ -749,14 +704,12 @@ function FallbackView({
|
|||||||
function StatsBar({
|
function StatsBar({
|
||||||
hasKnownWords,
|
hasKnownWords,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
seekCount,
|
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
session,
|
session,
|
||||||
lookupRate,
|
lookupRate,
|
||||||
}: {
|
}: {
|
||||||
hasKnownWords: boolean;
|
hasKnownWords: boolean;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
seekCount: number;
|
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||||
@@ -791,12 +744,7 @@ function StatsBar({
|
|||||||
{pauseCount !== 1 ? 's' : ''}
|
{pauseCount !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{seekCount > 0 && (
|
{pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
|
||||||
<span className="text-ctp-overlay2">
|
|
||||||
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
|
|
||||||
|
|
||||||
{/* Group 3: Learning events */}
|
{/* Group 3: Learning events */}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string {
|
|||||||
switch (marker.kind) {
|
switch (marker.kind) {
|
||||||
case 'pause':
|
case 'pause':
|
||||||
return '||';
|
return '||';
|
||||||
case 'seek':
|
|
||||||
return marker.direction === 'backward' ? '<<' : '>>';
|
|
||||||
case 'card':
|
case 'card':
|
||||||
return '\u26CF';
|
return '\u26CF';
|
||||||
}
|
}
|
||||||
@@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
|
|||||||
switch (marker.kind) {
|
switch (marker.kind) {
|
||||||
case 'pause':
|
case 'pause':
|
||||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
||||||
case 'seek':
|
|
||||||
return marker.direction === 'backward'
|
|
||||||
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
|
|
||||||
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
|
|
||||||
case 'card':
|
case 'card':
|
||||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,35 +41,6 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
|
|||||||
assert.match(markup, /Open in Anki/);
|
assert.match(markup, /Open in Anki/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SessionEventPopover renders seek metadata compactly', () => {
|
|
||||||
const marker: SessionChartMarker = {
|
|
||||||
key: 'seek-3000',
|
|
||||||
kind: 'seek',
|
|
||||||
anchorTsMs: 3_000,
|
|
||||||
eventTsMs: 3_000,
|
|
||||||
direction: 'backward',
|
|
||||||
fromMs: 5_000,
|
|
||||||
toMs: 1_500,
|
|
||||||
};
|
|
||||||
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<SessionEventPopover
|
|
||||||
marker={marker}
|
|
||||||
noteInfos={new Map()}
|
|
||||||
loading={false}
|
|
||||||
pinned={false}
|
|
||||||
onTogglePinned={() => {}}
|
|
||||||
onClose={() => {}}
|
|
||||||
onOpenNote={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(markup, /Seek backward/);
|
|
||||||
assert.match(markup, /5\.0s/);
|
|
||||||
assert.match(markup, /1\.5s/);
|
|
||||||
assert.match(markup, /3\.5s/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
|
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
|
||||||
const marker: SessionChartMarker = {
|
const marker: SessionChartMarker = {
|
||||||
key: 'card-9000',
|
key: 'card-9000',
|
||||||
|
|||||||
@@ -31,18 +31,12 @@ export function SessionEventPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
}: SessionEventPopoverProps) {
|
}: SessionEventPopoverProps) {
|
||||||
const seekDurationLabel =
|
|
||||||
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
|
|
||||||
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
||||||
<div className="mb-2 flex items-start justify-between gap-3">
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-ctp-text">
|
<div className="text-xs font-semibold text-ctp-text">
|
||||||
{marker.kind === 'pause' && 'Paused'}
|
{marker.kind === 'pause' && 'Paused'}
|
||||||
{marker.kind === 'seek' && `Seek ${marker.direction}`}
|
|
||||||
{marker.kind === 'card' && 'Card mined'}
|
{marker.kind === 'card' && 'Card mined'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||||
@@ -72,7 +66,6 @@ export function SessionEventPopover({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{marker.kind === 'pause' && '||'}
|
{marker.kind === 'pause' && '||'}
|
||||||
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
|
|
||||||
{marker.kind === 'card' && '\u26CF'}
|
{marker.kind === 'card' && '\u26CF'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,19 +77,6 @@ export function SessionEventPopover({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{marker.kind === 'seek' && (
|
|
||||||
<div className="space-y-1 text-xs text-ctp-subtext0">
|
|
||||||
<div>
|
|
||||||
From{' '}
|
|
||||||
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
|
|
||||||
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{marker.kind === 'card' && (
|
{marker.kind === 'card' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-ctp-cards-mined">
|
<div className="text-xs text-ctp-cards-mined">
|
||||||
|
|||||||
@@ -75,10 +75,7 @@ test('buildBucketDeleteHandler is a no-op when confirm returns false', async ()
|
|||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
let successCalled = false;
|
let successCalled = false;
|
||||||
|
|
||||||
const bucket = makeBucket([
|
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||||
makeSession({ sessionId: 1 }),
|
|
||||||
makeSession({ sessionId: 2 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handler = buildBucketDeleteHandler({
|
const handler = buildBucketDeleteHandler({
|
||||||
bucket,
|
bucket,
|
||||||
@@ -104,10 +101,7 @@ test('buildBucketDeleteHandler reports errors via onError without calling onSucc
|
|||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
let successCalled = false;
|
let successCalled = false;
|
||||||
|
|
||||||
const bucket = makeBucket([
|
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||||
makeSession({ sessionId: 1 }),
|
|
||||||
makeSession({ sessionId: 2 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handler = buildBucketDeleteHandler({
|
const handler = buildBucketDeleteHandler({
|
||||||
bucket,
|
bucket,
|
||||||
|
|||||||
@@ -269,9 +269,7 @@ export function SessionsTab({
|
|||||||
isExpanded={expandedId === s.sessionId}
|
isExpanded={expandedId === s.sessionId}
|
||||||
detailsId={detailsId}
|
detailsId={detailsId}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setExpandedId(
|
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||||
expandedId === s.sessionId ? null : s.sessionId,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onDelete={() => void handleDeleteSession(s)}
|
onDelete={() => void handleDeleteSession(s)}
|
||||||
deleteDisabled={deletingSessionId === s.sessionId}
|
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/);
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
filterHiddenAnimeData,
|
filterHiddenAnimeData,
|
||||||
pruneHiddenAnime,
|
pruneHiddenAnime,
|
||||||
} from './anime-visibility';
|
} from './anime-visibility';
|
||||||
|
import { LibrarySummarySection } from './LibrarySummarySection';
|
||||||
|
|
||||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -28,7 +29,7 @@ interface AnimeVisibilityFilterProps {
|
|||||||
onToggleAnime: (title: string) => void;
|
onToggleAnime: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnimeVisibilityFilter({
|
export function AnimeVisibilityFilter({
|
||||||
animeTitles,
|
animeTitles,
|
||||||
hiddenAnime,
|
hiddenAnime,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
@@ -44,7 +45,7 @@ function AnimeVisibilityFilter({
|
|||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
||||||
Anime Visibility
|
Title Visibility
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||||
Shared across all anime trend charts. Default: show everything.
|
Shared across all anime trend charts. Default: show everything.
|
||||||
@@ -114,11 +115,6 @@ export function TrendsTab() {
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const animeTitles = buildAnimeVisibilityOptions([
|
const animeTitles = buildAnimeVisibilityOptions([
|
||||||
data.animePerDay.episodes,
|
|
||||||
data.animePerDay.watchTime,
|
|
||||||
data.animePerDay.cards,
|
|
||||||
data.animePerDay.words,
|
|
||||||
data.animePerDay.lookups,
|
|
||||||
data.animeCumulative.episodes,
|
data.animeCumulative.episodes,
|
||||||
data.animeCumulative.cards,
|
data.animeCumulative.cards,
|
||||||
data.animeCumulative.words,
|
data.animeCumulative.words,
|
||||||
@@ -126,24 +122,6 @@ export function TrendsTab() {
|
|||||||
]);
|
]);
|
||||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||||
|
|
||||||
const filteredEpisodesPerAnime = filterHiddenAnimeData(
|
|
||||||
data.animePerDay.episodes,
|
|
||||||
activeHiddenAnime,
|
|
||||||
);
|
|
||||||
const filteredWatchTimePerAnime = filterHiddenAnimeData(
|
|
||||||
data.animePerDay.watchTime,
|
|
||||||
activeHiddenAnime,
|
|
||||||
);
|
|
||||||
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
|
||||||
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
|
||||||
const filteredLookupsPerAnime = filterHiddenAnimeData(
|
|
||||||
data.animePerDay.lookups,
|
|
||||||
activeHiddenAnime,
|
|
||||||
);
|
|
||||||
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
|
||||||
data.animePerDay.lookupsPerHundred,
|
|
||||||
activeHiddenAnime,
|
|
||||||
);
|
|
||||||
const filteredAnimeProgress = filterHiddenAnimeData(
|
const filteredAnimeProgress = filterHiddenAnimeData(
|
||||||
data.animeCumulative.episodes,
|
data.animeCumulative.episodes,
|
||||||
activeHiddenAnime,
|
activeHiddenAnime,
|
||||||
@@ -185,6 +163,18 @@ export function TrendsTab() {
|
|||||||
/>
|
/>
|
||||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||||
|
<TrendChart
|
||||||
|
title="Watch Time by Day of Week (min)"
|
||||||
|
data={data.patterns.watchTimeByDayOfWeek}
|
||||||
|
color="#8aadf4"
|
||||||
|
type="bar"
|
||||||
|
/>
|
||||||
|
<TrendChart
|
||||||
|
title="Watch Time by Hour (min)"
|
||||||
|
data={data.patterns.watchTimeByHour}
|
||||||
|
color="#c6a0f6"
|
||||||
|
type="bar"
|
||||||
|
/>
|
||||||
|
|
||||||
<SectionHeader>Period Trends</SectionHeader>
|
<SectionHeader>Period Trends</SectionHeader>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
@@ -221,7 +211,7 @@ export function TrendsTab() {
|
|||||||
type="line"
|
type="line"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader>Library — Per Day</SectionHeader>
|
<SectionHeader>Library — Cumulative</SectionHeader>
|
||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={animeTitles}
|
animeTitles={animeTitles}
|
||||||
hiddenAnime={activeHiddenAnime}
|
hiddenAnime={activeHiddenAnime}
|
||||||
@@ -239,21 +229,6 @@ export function TrendsTab() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="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="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
@@ -263,19 +238,8 @@ export function TrendsTab() {
|
|||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||||
|
|
||||||
<SectionHeader>Patterns</SectionHeader>
|
<SectionHeader>Library — Summary</SectionHeader>
|
||||||
<TrendChart
|
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
||||||
title="Watch Time by Day of Week (min)"
|
|
||||||
data={data.patterns.watchTimeByDayOfWeek}
|
|
||||||
color="#8aadf4"
|
|
||||||
type="bar"
|
|
||||||
/>
|
|
||||||
<TrendChart
|
|
||||||
title="Watch Time by Hour (min)"
|
|
||||||
data={data.patterns.watchTimeByHour}
|
|
||||||
color="#c6a0f6"
|
|
||||||
type="bar"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,5 +36,8 @@ test('omits reading when reading equals headword', () => {
|
|||||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||||
);
|
);
|
||||||
assert.ok(markup.includes('カレー'), 'should include the headword');
|
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>
|
<span className="text-ctp-text font-medium">{w.headword}</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const reading = fullReading(w.headword, w.reading);
|
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 (
|
return (
|
||||||
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
<span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>
|
||||||
【{reading}】
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -84,14 +84,7 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
|||||||
lookups: [],
|
lookups: [],
|
||||||
},
|
},
|
||||||
ratios: { lookupsPerHundred: [] },
|
ratios: { lookupsPerHundred: [] },
|
||||||
animePerDay: {
|
librarySummary: [],
|
||||||
episodes: [],
|
|
||||||
watchTime: [],
|
|
||||||
cards: [],
|
|
||||||
words: [],
|
|
||||||
lookups: [],
|
|
||||||
lookupsPerHundred: [],
|
|
||||||
},
|
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: [],
|
watchTime: [],
|
||||||
episodes: [],
|
episodes: [],
|
||||||
@@ -133,14 +126,7 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
|
|||||||
lookups: [],
|
lookups: [],
|
||||||
},
|
},
|
||||||
ratios: { lookupsPerHundred: [] },
|
ratios: { lookupsPerHundred: [] },
|
||||||
animePerDay: {
|
librarySummary: [],
|
||||||
episodes: [],
|
|
||||||
watchTime: [],
|
|
||||||
cards: [],
|
|
||||||
words: [],
|
|
||||||
lookups: [],
|
|
||||||
lookupsPerHundred: [],
|
|
||||||
},
|
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: [],
|
watchTime: [],
|
||||||
episodes: [],
|
episodes: [],
|
||||||
|
|||||||
@@ -65,16 +65,15 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(confirmBucketDelete('My Episode', 3), true);
|
assert.equal(confirmBucketDelete('My Episode', 3), true);
|
||||||
assert.equal(calls.length, 1);
|
assert.deepEqual(calls, [
|
||||||
assert.match(calls[0]!, /3/);
|
'Delete all 3 sessions of "My Episode" from this day and all associated data?',
|
||||||
assert.match(calls[0]!, /My Episode/);
|
]);
|
||||||
assert.match(calls[0]!, /sessions/);
|
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.confirm = originalConfirm;
|
globalThis.confirm = originalConfirm;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirmBucketDelete uses singular for one session', () => {
|
test('confirmBucketDelete uses a clean singular form for one session', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -84,7 +83,9 @@ test('confirmBucketDelete uses singular for one session', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(confirmBucketDelete('Solo Episode', 1), false);
|
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 {
|
} finally {
|
||||||
globalThis.confirm = originalConfirm;
|
globalThis.confirm = originalConfirm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function confirmEpisodeDelete(title: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function confirmBucketDelete(title: string, count: number): 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(
|
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?`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r
|
|||||||
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
|
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Seek events are intentionally dropped from the chart — they were too noisy.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
chartEvents.seekEvents.map((event) => event.eventType),
|
chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
|
||||||
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
|
[],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
chartEvents.cardEvents.map((event) => event.tsMs),
|
chartEvents.cardEvents.map((event) => event.tsMs),
|
||||||
|
|||||||
@@ -29,25 +29,20 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
|
|||||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
|
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Seek events are intentionally dropped — too noisy on the session chart.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
chartEvents.markers.map((marker) => marker.kind),
|
chartEvents.markers.map((marker) => marker.kind),
|
||||||
['seek', 'pause', 'card'],
|
['pause', 'card'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const seekMarker = chartEvents.markers[0]!;
|
const pauseMarker = chartEvents.markers[0]!;
|
||||||
assert.equal(seekMarker.kind, 'seek');
|
|
||||||
assert.equal(seekMarker.direction, 'forward');
|
|
||||||
assert.equal(seekMarker.fromMs, 1_000);
|
|
||||||
assert.equal(seekMarker.toMs, 5_500);
|
|
||||||
|
|
||||||
const pauseMarker = chartEvents.markers[1]!;
|
|
||||||
assert.equal(pauseMarker.kind, 'pause');
|
assert.equal(pauseMarker.kind, 'pause');
|
||||||
assert.equal(pauseMarker.startMs, 2_000);
|
assert.equal(pauseMarker.startMs, 2_000);
|
||||||
assert.equal(pauseMarker.endMs, 5_000);
|
assert.equal(pauseMarker.endMs, 5_000);
|
||||||
assert.equal(pauseMarker.durationMs, 3_000);
|
assert.equal(pauseMarker.durationMs, 3_000);
|
||||||
assert.equal(pauseMarker.anchorTsMs, 3_500);
|
assert.equal(pauseMarker.anchorTsMs, 3_500);
|
||||||
|
|
||||||
const cardMarker = chartEvents.markers[2]!;
|
const cardMarker = chartEvents.markers[1]!;
|
||||||
assert.equal(cardMarker.kind, 'card');
|
assert.equal(cardMarker.kind, 'card');
|
||||||
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
||||||
assert.equal(cardMarker.cardsDelta, 2);
|
assert.equal(cardMarker.cardsDelta, 2);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { EventType, type SessionEvent } from '../types/stats';
|
|||||||
|
|
||||||
export const SESSION_CHART_EVENT_TYPES = [
|
export const SESSION_CHART_EVENT_TYPES = [
|
||||||
EventType.CARD_MINED,
|
EventType.CARD_MINED,
|
||||||
EventType.SEEK_FORWARD,
|
|
||||||
EventType.SEEK_BACKWARD,
|
|
||||||
EventType.PAUSE_START,
|
EventType.PAUSE_START,
|
||||||
EventType.PAUSE_END,
|
EventType.PAUSE_END,
|
||||||
EventType.YOMITAN_LOOKUP,
|
EventType.YOMITAN_LOOKUP,
|
||||||
@@ -16,7 +14,6 @@ export interface PauseRegion {
|
|||||||
|
|
||||||
export interface SessionChartEvents {
|
export interface SessionChartEvents {
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
seekEvents: SessionEvent[];
|
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: PauseRegion[];
|
pauseRegions: PauseRegion[];
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
@@ -58,15 +55,6 @@ export type SessionChartMarker =
|
|||||||
endMs: number;
|
endMs: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
key: string;
|
|
||||||
kind: 'seek';
|
|
||||||
anchorTsMs: number;
|
|
||||||
eventTsMs: number;
|
|
||||||
direction: 'forward' | 'backward';
|
|
||||||
fromMs: number | null;
|
|
||||||
toMs: number | null;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
key: string;
|
key: string;
|
||||||
kind: 'card';
|
kind: 'card';
|
||||||
@@ -295,7 +283,6 @@ export function projectSessionMarkerLeftPx({
|
|||||||
|
|
||||||
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
||||||
const cardEvents: SessionEvent[] = [];
|
const cardEvents: SessionEvent[] = [];
|
||||||
const seekEvents: SessionEvent[] = [];
|
|
||||||
const yomitanLookupEvents: SessionEvent[] = [];
|
const yomitanLookupEvents: SessionEvent[] = [];
|
||||||
const pauseRegions: PauseRegion[] = [];
|
const pauseRegions: PauseRegion[] = [];
|
||||||
const markers: SessionChartMarker[] = [];
|
const markers: SessionChartMarker[] = [];
|
||||||
@@ -317,22 +304,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case EventType.SEEK_FORWARD:
|
|
||||||
case EventType.SEEK_BACKWARD:
|
|
||||||
seekEvents.push(event);
|
|
||||||
{
|
|
||||||
const payload = parsePayload(event.payload);
|
|
||||||
markers.push({
|
|
||||||
key: `seek-${event.tsMs}-${event.eventType}`,
|
|
||||||
kind: 'seek',
|
|
||||||
anchorTsMs: event.tsMs,
|
|
||||||
eventTsMs: event.tsMs,
|
|
||||||
direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward',
|
|
||||||
fromMs: readNumberField(payload?.fromMs),
|
|
||||||
toMs: readNumberField(payload?.toMs),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case EventType.YOMITAN_LOOKUP:
|
case EventType.YOMITAN_LOOKUP:
|
||||||
yomitanLookupEvents.push(event);
|
yomitanLookupEvents.push(event);
|
||||||
break;
|
break;
|
||||||
@@ -376,7 +347,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
cardEvents,
|
cardEvents,
|
||||||
seekEvents,
|
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
markers,
|
markers,
|
||||||
|
|||||||
@@ -32,8 +32,20 @@ test('empty input returns empty array', () => {
|
|||||||
|
|
||||||
test('two unique videoIds produce 2 singleton buckets', () => {
|
test('two unique videoIds produce 2 singleton buckets', () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
makeSession({ sessionId: 1, videoId: 10, startedAtMs: 1000, activeWatchedMs: 100, cardsMined: 2 }),
|
makeSession({
|
||||||
makeSession({ sessionId: 2, videoId: 20, startedAtMs: 2000, activeWatchedMs: 200, cardsMined: 3 }),
|
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);
|
const buckets = groupSessionsByVideo(sessions);
|
||||||
assert.equal(buckets.length, 2);
|
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', () => {
|
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 older = makeSession({
|
||||||
const newer = makeSession({ sessionId: 2, videoId: 42, startedAtMs: 9000, activeWatchedMs: 500, cardsMined: 7 });
|
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]);
|
const buckets = groupSessionsByVideo([older, newer]);
|
||||||
assert.equal(buckets.length, 1);
|
assert.equal(buckets.length, 1);
|
||||||
const [bucket] = buckets;
|
const [bucket] = buckets;
|
||||||
|
|||||||
@@ -288,6 +288,19 @@ export interface TrendPerAnimePoint {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibrarySummaryRow {
|
||||||
|
title: string;
|
||||||
|
watchTimeMin: number;
|
||||||
|
videos: number;
|
||||||
|
sessions: number;
|
||||||
|
cards: number;
|
||||||
|
words: number;
|
||||||
|
lookups: number;
|
||||||
|
lookupsPerHundred: number | null;
|
||||||
|
firstWatched: number;
|
||||||
|
lastWatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrendsDashboardData {
|
export interface TrendsDashboardData {
|
||||||
activity: {
|
activity: {
|
||||||
watchTime: TrendChartPoint[];
|
watchTime: TrendChartPoint[];
|
||||||
@@ -307,14 +320,7 @@ export interface TrendsDashboardData {
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: TrendChartPoint[];
|
lookupsPerHundred: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
animePerDay: {
|
librarySummary: LibrarySummaryRow[];
|
||||||
episodes: TrendPerAnimePoint[];
|
|
||||||
watchTime: TrendPerAnimePoint[];
|
|
||||||
cards: TrendPerAnimePoint[];
|
|
||||||
words: TrendPerAnimePoint[];
|
|
||||||
lookups: TrendPerAnimePoint[];
|
|
||||||
lookupsPerHundred: TrendPerAnimePoint[];
|
|
||||||
};
|
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: TrendPerAnimePoint[];
|
watchTime: TrendPerAnimePoint[];
|
||||||
episodes: TrendPerAnimePoint[];
|
episodes: TrendPerAnimePoint[];
|
||||||
|
|||||||
Reference in New Issue
Block a user