mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
feat(anime): add anime browser powered by Aniyomi extensions
- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached - Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config - Support searching all sources at once with streaming, per-source results and status - Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese - Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon - Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
@@ -64,15 +64,17 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
|
||||
```typescript
|
||||
interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // raw subtitle text
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // raw subtitle text
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
||||
@@ -153,6 +155,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||
### Dependency Analysis
|
||||
|
||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||
|
||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||
@@ -169,18 +172,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
||||
|
||||
// Single pass: known word + frequency filtering + JLPT computed together
|
||||
const annotated = tokens.map((token) => {
|
||||
const isKnown = nPlusOneEnabled
|
||||
? token.isKnown || computeIsKnown(token, deps)
|
||||
: false;
|
||||
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||
|
||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||
const frequencyRank = frequencyEnabled
|
||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled
|
||||
? computeJlptLevel(token, deps.getJlptLevel)
|
||||
: undefined;
|
||||
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||
|
||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||
});
|
||||
@@ -221,6 +220,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
||||
### Current Behavior
|
||||
|
||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||
|
||||
1. Clears DOM with `innerHTML = ''`
|
||||
2. Creates a `DocumentFragment`
|
||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||
@@ -256,27 +256,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
||||
|
||||
## Combined Impact Summary
|
||||
|
||||
| Scenario | Before | After | Improvement |
|
||||
|----------|--------|-------|-------------|
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
| Scenario | Before | After | Improvement |
|
||||
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/core/services/subtitle-prefetch.ts`
|
||||
- `src/core/services/subtitle-cue-parser.ts`
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||
- `src/main.ts` (wire up prefetch service)
|
||||
|
||||
### Test Files
|
||||
|
||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||
- Updated tests for annotation stage (same behavior, new implementation)
|
||||
|
||||
@@ -7,21 +7,21 @@ Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: finding internal docs or checking verification status
|
||||
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| ------------------------ | ----------------------------------------------- | ------ | ------------- | ------------------------------------------ |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
|
||||
## Update Rules
|
||||
|
||||
|
||||
@@ -11,27 +11,27 @@ Grades are directional, not ceremonial. The point is to keep gaps visible.
|
||||
|
||||
## Product / Runtime Domains
|
||||
|
||||
| Area | Grade | Notes |
|
||||
| --- | --- | --- |
|
||||
| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
|
||||
| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
|
||||
| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
|
||||
| Overlay renderer | B | improved modularity; interaction complexity remains |
|
||||
| Config system | A- | clear defaults/definitions split and good validation surface |
|
||||
| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
|
||||
| Internal docs system | B | new structure in place; needs habitual maintenance |
|
||||
| Public docs site | B | strong user docs; must stay separate from internal KB |
|
||||
| Area | Grade | Notes |
|
||||
| --------------------------------------- | ----- | ------------------------------------------------------------------------ |
|
||||
| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
|
||||
| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
|
||||
| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
|
||||
| Overlay renderer | B | improved modularity; interaction complexity remains |
|
||||
| Config system | A- | clear defaults/definitions split and good validation surface |
|
||||
| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
|
||||
| Internal docs system | B | new structure in place; needs habitual maintenance |
|
||||
| Public docs site | B | strong user docs; must stay separate from internal KB |
|
||||
|
||||
## Architectural Layers
|
||||
|
||||
| Layer | Grade | Notes |
|
||||
| --- | --- | --- |
|
||||
| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
|
||||
| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
|
||||
| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
|
||||
| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
|
||||
| `launcher/` | B | clear command boundaries |
|
||||
| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
|
||||
| Layer | Grade | Notes |
|
||||
| ------------------------------ | ----- | --------------------------------------------------------- |
|
||||
| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
|
||||
| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
|
||||
| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
|
||||
| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
|
||||
| `launcher/` | B | clear command boundaries |
|
||||
| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
|
||||
|
||||
## Current Gaps
|
||||
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
## File Structure
|
||||
|
||||
**Backend (`src/core/services/immersion-tracker/`):**
|
||||
|
||||
- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`.
|
||||
- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred).
|
||||
|
||||
**Backend test fixtures:**
|
||||
|
||||
- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`.
|
||||
|
||||
**Frontend (`stats/src/`):**
|
||||
|
||||
- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field.
|
||||
- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`).
|
||||
- `components/trends/LibrarySummarySection.tsx` — **new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props.
|
||||
@@ -29,6 +32,7 @@
|
||||
- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision).
|
||||
|
||||
**Changelog:**
|
||||
|
||||
- `changes/stats-library-summary.md` — **new** changelog fragment.
|
||||
|
||||
---
|
||||
@@ -36,6 +40,7 @@
|
||||
## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
|
||||
|
||||
- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object**
|
||||
@@ -82,6 +87,7 @@ git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field"
|
||||
## Task 2: Backend — TDD the `buildLibrarySummary` helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
|
||||
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
|
||||
@@ -160,16 +166,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
|
||||
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(
|
||||
`${startedAtMs + activeMs}`,
|
||||
activeMs,
|
||||
activeMs,
|
||||
10,
|
||||
tokens,
|
||||
cards,
|
||||
lookups,
|
||||
sessionId,
|
||||
);
|
||||
).run(`${startedAtMs + activeMs}`, activeMs, activeMs, 10, tokens, cards, lookups, sessionId);
|
||||
}
|
||||
|
||||
for (const [day, active, tokens, cards] of [
|
||||
@@ -289,8 +286,7 @@ function buildLibrarySummary(
|
||||
cards: acc.cards,
|
||||
words: acc.words,
|
||||
lookups: acc.lookups,
|
||||
lookupsPerHundred:
|
||||
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
|
||||
lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
|
||||
firstWatched: acc.firstWatched,
|
||||
lastWatched: acc.lastWatched,
|
||||
});
|
||||
@@ -334,6 +330,7 @@ git commit -m "feat(stats): build per-title librarySummary from daily rollups an
|
||||
## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0**
|
||||
@@ -402,16 +399,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
|
||||
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,
|
||||
);
|
||||
).run(`${startMs + 20 * 60_000}`, 20 * 60_000, 20 * 60_000, 5, 0, 0, 0, session.sessionId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
@@ -464,6 +452,7 @@ git commit -m "test(stats): cover librarySummary null-lookups and empty-window c
|
||||
## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
|
||||
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
- Modify: `src/core/services/__tests__/stats-server.test.ts`
|
||||
@@ -489,61 +478,61 @@ animePerDay: {
|
||||
In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement:
|
||||
|
||||
```ts
|
||||
const animePerDay = {
|
||||
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
||||
watchTime: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalActiveMin,
|
||||
),
|
||||
cards: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalCards,
|
||||
),
|
||||
words: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalTokensSeen,
|
||||
),
|
||||
};
|
||||
const animePerDay = {
|
||||
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
||||
watchTime: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalActiveMin,
|
||||
),
|
||||
cards: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalCards,
|
||||
),
|
||||
words: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalTokensSeen,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
activity,
|
||||
progress: {
|
||||
watchTime: accumulatePoints(activity.watchTime),
|
||||
sessions: accumulatePoints(activity.sessions),
|
||||
words: accumulatePoints(activity.words),
|
||||
newWords: accumulatePoints(
|
||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||
),
|
||||
cards: accumulatePoints(activity.cards),
|
||||
episodes: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||
),
|
||||
lookups: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
),
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
},
|
||||
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
||||
animeCumulative: {
|
||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
||||
cards: buildCumulativePerAnime(animePerDay.cards),
|
||||
words: buildCumulativePerAnime(animePerDay.words),
|
||||
},
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||
},
|
||||
};
|
||||
return {
|
||||
activity,
|
||||
progress: {
|
||||
watchTime: accumulatePoints(activity.watchTime),
|
||||
sessions: accumulatePoints(activity.sessions),
|
||||
words: accumulatePoints(activity.words),
|
||||
newWords: accumulatePoints(
|
||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||
),
|
||||
cards: accumulatePoints(activity.cards),
|
||||
episodes: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||
),
|
||||
lookups: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
),
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
},
|
||||
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
||||
animeCumulative: {
|
||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
||||
cards: buildCumulativePerAnime(animePerDay.cards),
|
||||
words: buildCumulativePerAnime(animePerDay.words),
|
||||
},
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete now-unused helpers**
|
||||
@@ -622,6 +611,7 @@ git commit -m "refactor(stats): drop animePerDay from trends response in favor o
|
||||
## Task 5: Frontend — Update types and api-client test fixtures
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/types/stats.ts`
|
||||
- Modify: `stats/src/lib/api-client.test.ts`
|
||||
|
||||
@@ -710,6 +700,7 @@ git commit -m "refactor(stats): replace animePerDay type with librarySummary"
|
||||
## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `stats/src/components/trends/LibrarySummarySection.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the file with the empty state and props plumbing**
|
||||
@@ -765,6 +756,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
|
||||
## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace the skeleton body with the leaderboard chart**
|
||||
@@ -772,15 +764,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
|
||||
Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
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';
|
||||
|
||||
@@ -821,9 +805,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
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>
|
||||
<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}
|
||||
@@ -881,6 +863,7 @@ git commit -m "feat(stats): add top-titles leaderboard chart to LibrarySummarySe
|
||||
## Task 8: Frontend — Add the sortable table to `LibrarySummarySection`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
|
||||
|
||||
- [ ] **Step 1: Add sort state, column definitions, and the table markup**
|
||||
@@ -889,15 +872,7 @@ Replace the entire file with the version below. The change vs. Task 7: imports `
|
||||
|
||||
```tsx
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
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';
|
||||
@@ -1023,9 +998,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
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 className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1042,9 +1015,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
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>
|
||||
<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}
|
||||
@@ -1081,10 +1052,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
</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 }}
|
||||
>
|
||||
<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">
|
||||
@@ -1138,9 +1106,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
{formatNumber(row.lookups)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{row.lookupsPerHundred === null
|
||||
? '—'
|
||||
: row.lookupsPerHundred.toFixed(1)}
|
||||
{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)}
|
||||
@@ -1173,6 +1139,7 @@ git commit -m "feat(stats): add sortable per-title table to LibrarySummarySectio
|
||||
## Task 9: Frontend — Wire `LibrarySummarySection` into `TrendsTab` and remove the per-day block
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/trends/TrendsTab.tsx`
|
||||
|
||||
- [ ] **Step 1: Delete the per-day filtered locals and imports**
|
||||
@@ -1192,10 +1159,7 @@ const filteredWatchTimePerAnime = filterHiddenAnimeData(
|
||||
);
|
||||
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
||||
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
||||
const filteredLookupsPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookups,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
|
||||
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookupsPerHundred,
|
||||
activeHiddenAnime,
|
||||
@@ -1286,6 +1250,7 @@ git commit -m "feat(stats): replace per-day trends section with library summary"
|
||||
## Task 10: Add changelog fragment and run the full handoff gate
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `changes/stats-library-summary.md`
|
||||
|
||||
- [ ] **Step 1: Check the existing changelog fragment format**
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
## Task 1: 365d range — backend type extension
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88`
|
||||
- Test: `src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
|
||||
@@ -101,13 +102,14 @@
|
||||
## Task 2: 365d range — server route allow-list
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`)
|
||||
- Test: `src/core/services/__tests__/stats-server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Locate the trends route in `stats-server.ts`**
|
||||
|
||||
Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts`
|
||||
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue.
|
||||
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there _is_ an allow-list (e.g. a `validRanges` array), continue.
|
||||
|
||||
- [ ] **Step 2: Add a failing test for `range=365d`**
|
||||
|
||||
@@ -145,6 +147,7 @@
|
||||
## Task 3: 365d range — frontend client and selector
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/lib/api-client.ts`
|
||||
- Modify: `stats/src/lib/api-client.test.ts`
|
||||
- Modify: `stats/src/hooks/useTrends.ts:5`
|
||||
@@ -175,10 +178,13 @@
|
||||
- [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control**
|
||||
|
||||
In `stats/src/components/trends/DateRangeSelector.tsx:56`, change:
|
||||
|
||||
```tsx
|
||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||
```
|
||||
@@ -206,6 +212,7 @@
|
||||
## Task 4: Vocabulary Top 50 — collapse word/reading column
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144`
|
||||
- Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`)
|
||||
|
||||
@@ -217,6 +224,7 @@
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
@@ -271,6 +279,7 @@
|
||||
Replace the `<th>Reading</th>` header column and the corresponding `<td>` in the body. The new shape:
|
||||
|
||||
Header (around line 113-119):
|
||||
|
||||
```tsx
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
@@ -283,6 +292,7 @@
|
||||
```
|
||||
|
||||
Body row (around line 122-141):
|
||||
|
||||
```tsx
|
||||
<tr
|
||||
key={w.wordId}
|
||||
@@ -297,16 +307,10 @@
|
||||
{(() => {
|
||||
const reading = fullReading(w.headword, w.reading);
|
||||
if (!reading || reading === w.headword) return null;
|
||||
return (
|
||||
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
||||
【{reading}】
|
||||
</span>
|
||||
);
|
||||
return <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>;
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}</td>
|
||||
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
|
||||
{w.frequency}x
|
||||
</td>
|
||||
@@ -336,6 +340,7 @@
|
||||
## Task 5: Episode detail — filter Anki-deleted cards
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147`
|
||||
- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present
|
||||
|
||||
@@ -410,11 +415,13 @@
|
||||
Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `</div>` closing the cards-mined section, add:
|
||||
|
||||
```tsx
|
||||
{hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Place that footer immediately before the closing `</div>` of the bordered cards-mined section, so it stays scoped to that block.
|
||||
@@ -422,12 +429,14 @@
|
||||
**Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`.
|
||||
|
||||
Concrete change near line 22:
|
||||
|
||||
```tsx
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
||||
```
|
||||
|
||||
Inside the existing `useEffect` (around line 36-46), set the loaded flag:
|
||||
|
||||
```tsx
|
||||
if (allNoteIds.length > 0) {
|
||||
getStatsClient()
|
||||
@@ -452,6 +461,7 @@
|
||||
```
|
||||
|
||||
And gate the filter:
|
||||
|
||||
```tsx
|
||||
const filteredCardEvents = noteInfosLoaded
|
||||
? cardEvents
|
||||
@@ -496,6 +506,7 @@
|
||||
## Task 6: Library detail — delete episode action
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/library/MediaHeader.tsx`
|
||||
- Modify: `stats/src/components/library/MediaDetailView.tsx`
|
||||
- Modify: `stats/src/hooks/useMediaLibrary.ts`
|
||||
@@ -553,9 +564,7 @@
|
||||
|
||||
```tsx
|
||||
<div className="flex items-start gap-2">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">
|
||||
{detail.canonicalTitle}
|
||||
</h2>
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">{detail.canonicalTitle}</h2>
|
||||
{onDeleteEpisode && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -718,6 +727,7 @@
|
||||
## Task 7: Library — collapsible series groups
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/library/LibraryTab.tsx`
|
||||
- Test: create `stats/src/components/library/LibraryTab.test.tsx`
|
||||
|
||||
@@ -758,11 +768,13 @@
|
||||
- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`**
|
||||
|
||||
Modify imports:
|
||||
|
||||
```tsx
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
```
|
||||
|
||||
Inside the component, after the existing `useState` calls:
|
||||
|
||||
```tsx
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
|
||||
|
||||
@@ -780,6 +792,7 @@
|
||||
```
|
||||
|
||||
Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**:
|
||||
|
||||
```tsx
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
|
||||
const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false);
|
||||
@@ -814,71 +827,71 @@
|
||||
Replace the section block (around line 64-115) so the header is a `<button>`:
|
||||
|
||||
```tsx
|
||||
{grouped.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.key);
|
||||
const isSingleVideo = group.items.length === 1;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSingleVideo && toggleGroup(group.key)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-controls={`group-body-${group.key}`}
|
||||
disabled={isSingleVideo}
|
||||
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
|
||||
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
|
||||
}`}
|
||||
{
|
||||
grouped.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.key);
|
||||
const isSingleVideo = group.items.length === 1;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
{!isSingleVideo && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
|
||||
isCollapsed ? '' : 'rotate-90'
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSingleVideo && toggleGroup(group.key)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-controls={`group-body-${group.key}`}
|
||||
disabled={isSingleVideo}
|
||||
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
|
||||
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{!isSingleVideo && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
|
||||
isCollapsed ? '' : 'rotate-90'
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
)}
|
||||
<CoverImage
|
||||
videoId={group.items[0]!.videoId}
|
||||
title={group.title}
|
||||
src={group.imageUrl}
|
||||
className="w-16 h-16 rounded-2xl shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-ctp-text truncate">{group.title}</h3>
|
||||
</div>
|
||||
{group.subtitle ? (
|
||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div id={`group-body-${group.key}`} className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{group.items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CoverImage
|
||||
videoId={group.items[0]!.videoId}
|
||||
title={group.title}
|
||||
src={group.imageUrl}
|
||||
className="w-16 h-16 rounded-2xl shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-ctp-text truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
</div>
|
||||
{group.subtitle ? (
|
||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div id={`group-body-${group.key}`} className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{group.items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption.
|
||||
@@ -906,6 +919,7 @@
|
||||
## Task 8: Session grouping helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `stats/src/lib/session-grouping.ts`
|
||||
- Create: `stats/src/lib/session-grouping.test.ts`
|
||||
|
||||
@@ -1012,7 +1026,9 @@
|
||||
|
||||
for (const session of sessions) {
|
||||
const hasVideoId =
|
||||
typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0;
|
||||
typeof session.videoId === 'number' &&
|
||||
Number.isFinite(session.videoId) &&
|
||||
session.videoId > 0;
|
||||
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
|
||||
const existing = byVideo.get(key);
|
||||
if (existing) {
|
||||
@@ -1066,6 +1082,7 @@
|
||||
## Task 9: Sessions tab — episode rollup UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/sessions/SessionsTab.tsx`
|
||||
- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`)
|
||||
- Modify: `stats/src/lib/delete-confirm.test.ts`
|
||||
@@ -1161,114 +1178,120 @@
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const buckets = groupSessionsByVideo(daySessions);
|
||||
return (
|
||||
<div key={dayLabel}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) => {
|
||||
if (bucket.sessions.length === 1) {
|
||||
const s = bucket.sessions[0]!;
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
{
|
||||
Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const buckets = groupSessionsByVideo(daySessions);
|
||||
return (
|
||||
<div key={dayLabel}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) => {
|
||||
if (bucket.sessions.length === 1) {
|
||||
const s = bucket.sessions[0]!;
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isOpen = expandedBuckets.has(bucket.key);
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
<div
|
||||
key={bucket.key}
|
||||
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleBucket(bucket.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">
|
||||
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{bucket.sessions.length} sessions · {formatDuration(bucket.totalActiveMs)} ·{' '}
|
||||
{bucket.totalCardsMined} cards
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteBucket(bucket);
|
||||
}}
|
||||
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
|
||||
title="Delete all sessions in this group"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="pl-8 pr-2 pb-2 space-y-2">
|
||||
{bucket.sessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isOpen = expandedBuckets.has(bucket.key);
|
||||
return (
|
||||
<div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleBucket(bucket.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">
|
||||
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{bucket.sessions.length} sessions ·{' '}
|
||||
{formatDuration(bucket.totalActiveMs)} ·{' '}
|
||||
{bucket.totalCardsMined} cards
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteBucket(bucket);
|
||||
}}
|
||||
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
|
||||
title="Delete all sessions in this group"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="pl-8 pr-2 pb-2 space-y-2">
|
||||
{bucket.sessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics:
|
||||
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container _outside_ the toggle button. Pick the second option — it's accessible without role gymnastics:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center">
|
||||
@@ -1281,7 +1304,7 @@
|
||||
</div>
|
||||
```
|
||||
|
||||
Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones.
|
||||
Use that pattern in the actual implementation. The skeleton above shows the _intent_; the final code must have sibling buttons, not nested ones.
|
||||
|
||||
Add `handleDeleteBucket`:
|
||||
|
||||
@@ -1336,6 +1359,7 @@
|
||||
## Task 10: Chart clarity pass
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/lib/chart-theme.ts`
|
||||
- Modify: `stats/src/components/trends/TrendChart.tsx`
|
||||
- Modify: `stats/src/components/trends/StackedTrendChart.tsx`
|
||||
@@ -1528,6 +1552,7 @@
|
||||
## Task 11: Changelog fragment
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md`
|
||||
|
||||
- [ ] **Step 1: Read the existing changelog format**
|
||||
|
||||
@@ -46,16 +46,16 @@ 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
|
||||
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
|
||||
firstWatched: number; // min(rollup_day) as epoch day, within the window
|
||||
lastWatched: number; // max(rollup_day) as epoch day, within the window
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Out of scope for this pass: English-token ingestion cleanup and Overview stat-ca
|
||||
## Files touched (inventory)
|
||||
|
||||
Dashboard (`stats/src/`):
|
||||
|
||||
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
|
||||
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
|
||||
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
|
||||
@@ -31,6 +32,7 @@ Dashboard (`stats/src/`):
|
||||
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
|
||||
|
||||
Backend (`src/core/services/`):
|
||||
|
||||
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
|
||||
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
|
||||
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
|
||||
@@ -93,9 +95,10 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
|
||||
### Implementation
|
||||
|
||||
- New helper in `stats/src/lib/session-grouping.ts`:
|
||||
|
||||
```ts
|
||||
export interface SessionBucket {
|
||||
key: string; // videoId as string, or `s-${sessionId}` for singletons
|
||||
key: string; // videoId as string, or `s-${sessionId}` for singletons
|
||||
videoId: number | null;
|
||||
sessions: SessionSummary[];
|
||||
totalActiveMs: number;
|
||||
@@ -104,6 +107,7 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
|
||||
}
|
||||
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
|
||||
```
|
||||
|
||||
Sessions missing a `videoId` become singleton buckets.
|
||||
|
||||
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
|
||||
@@ -132,11 +136,13 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
|
||||
### Backend
|
||||
|
||||
`src/core/services/immersion-tracker/query-trends.ts`:
|
||||
|
||||
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
|
||||
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
|
||||
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
|
||||
|
||||
`src/core/services/stats-server.ts`:
|
||||
|
||||
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
|
||||
|
||||
### Frontend
|
||||
@@ -202,11 +208,7 @@ Merge Word + Reading into a single column titled "Word". Reading sits immediatel
|
||||
```tsx
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="text-ctp-text font-medium">{w.headword}</span>
|
||||
{reading && (
|
||||
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
||||
【{reading}】
|
||||
</span>
|
||||
)}
|
||||
{reading && <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>}
|
||||
</td>
|
||||
```
|
||||
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
|
||||
@@ -230,6 +232,7 @@ Merge Word + Reading into a single column titled "Word". Reading sits immediatel
|
||||
### Target behavior
|
||||
|
||||
After `ankiNotesInfo` resolves:
|
||||
|
||||
- Drop `noteId`s that are not in the resolved map.
|
||||
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
|
||||
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
|
||||
@@ -255,6 +258,7 @@ After `ankiNotesInfo` resolves:
|
||||
### Current behavior
|
||||
|
||||
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
|
||||
|
||||
- No `CartesianGrid` → no horizontal reference lines.
|
||||
- 9px axis ticks → borderline unreadable.
|
||||
- Height 120 → cramped.
|
||||
@@ -270,6 +274,7 @@ All three charts share a theme, have horizontal gridlines, readable ticks, and s
|
||||
### Implementation
|
||||
|
||||
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
|
||||
|
||||
```ts
|
||||
export const CHART_THEME = {
|
||||
tick: '#a5adcb',
|
||||
@@ -299,6 +304,7 @@ export const TOOLTIP_CONTENT_STYLE = {
|
||||
```
|
||||
|
||||
Apply to each chart:
|
||||
|
||||
- Import `CartesianGrid` from recharts.
|
||||
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
|
||||
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
|
||||
|
||||
Reference in New Issue
Block a user