fix(anime): scope preferences, paginate sources, harden installs

- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first
- Source picker's "Load more" appends the next page without duplicating streamed results
- Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded
- APK installs are staged to a temp file and renamed into place
- Stream metadata lookup matches the requested path, not only the currently playing one
- Reworked animeui into browse-state/detail-panel/panels.css modules
- Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
This commit is contained in:
2026-08-03 02:16:22 -07:00
parent d76e3c2a39
commit c2917ac1a5
52 changed files with 2471 additions and 1598 deletions
@@ -15,16 +15,13 @@
## 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.
@@ -32,7 +29,6 @@
- `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.
---
@@ -40,7 +36,6 @@
## 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**
@@ -87,7 +82,6 @@ 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`
@@ -166,7 +160,16 @@ 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 [
@@ -286,7 +289,8 @@ 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,
});
@@ -330,7 +334,6 @@ 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**
@@ -399,7 +402,16 @@ 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(
`
@@ -452,7 +464,6 @@ 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`
@@ -478,61 +489,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**
@@ -611,7 +622,6 @@ 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`
@@ -700,7 +710,6 @@ 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**
@@ -756,7 +765,6 @@ 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**
@@ -764,7 +772,15 @@ 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';
@@ -805,7 +821,9 @@ 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}
@@ -863,7 +881,6 @@ 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**
@@ -872,7 +889,15 @@ 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';
@@ -998,7 +1023,9 @@ 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>
);
}
@@ -1015,7 +1042,9 @@ 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}
@@ -1052,7 +1081,10 @@ 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">
@@ -1106,7 +1138,9 @@ 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)}
@@ -1139,7 +1173,6 @@ 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**
@@ -1159,7 +1192,10 @@ 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,
@@ -1250,7 +1286,6 @@ 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**