feat(stats): add Reading Speed and Cards/Hour efficiency charts to Trend

- Add cardsPerHour and readingSpeed series to TrendsDashboardQueryResult and TrendsDashboardData
- Reorganize Trends tab into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections
- Disambiguate per-period vs cumulative chart titles
- Label Activity section header with current groupBy period
This commit is contained in:
2026-06-07 00:27:13 -07:00
parent e0e9c182ee
commit 36ec5c1b93
6 changed files with 100 additions and 37 deletions
+1
View File
@@ -5,4 +5,5 @@ area: stats
- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown.
- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- Improved vocabulary review with remembered Hide Known/Hide Kana filters, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji.
- Reorganized the Stats Trends tab into clearer Activity, Cumulative Totals, Efficiency, Patterns, and Library sections, disambiguated per-period vs cumulative charts, and added Reading Speed (words/min) and Cards/Hour efficiency charts.
- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
+1 -1
View File
@@ -54,7 +54,7 @@ When YouTube channel metadata is available, the Library tab groups videos by cre
#### Trends
Watch time, sessions, words seen, and per-anime progress/pattern charts with configurable date ranges and grouping.
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words learned and episodes), Efficiency (reading speed, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
![Stats Trends](/screenshots/stats-trends.png)
@@ -761,6 +761,10 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18);
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(3 / (45 / 60)).toFixed(1));
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(140 / 45).toFixed(1));
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
assert.equal(
@@ -74,6 +74,8 @@ export interface TrendsDashboardQueryResult {
};
ratios: {
lookupsPerHundred: TrendChartPoint[];
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
};
animeCumulative: {
watchTime: TrendPerAnimePoint[];
@@ -225,6 +227,26 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
}));
}
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
} {
const cardsPerHour: TrendChartPoint[] = [];
const readingSpeed: TrendChartPoint[] = [];
for (const row of rows) {
const hours = row.activeMin / 60;
cardsPerHour.push({
label: row.label,
value: hours > 0 ? +(row.cards / hours).toFixed(1) : 0,
});
readingSpeed.push({
label: row.label,
value: row.activeMin > 0 ? +(row.words / row.activeMin).toFixed(1) : 0,
});
}
return { cardsPerHour, readingSpeed };
}
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(7).fill(0);
for (const session of sessions) {
@@ -675,6 +697,7 @@ export function getTrendsDashboard(
);
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
@@ -724,6 +747,8 @@ export function getTrendsDashboard(
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed,
},
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
+67 -36
View File
@@ -148,7 +148,7 @@ export function TrendsTab() {
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader>
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.activity.watchTime}
@@ -163,6 +163,72 @@ export function TrendsTab() {
/>
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Cumulative Totals</SectionHeader>
<TrendChart
title="Watch Time, cumulative (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart
title="Words Seen (cumulative)"
data={data.progress.words}
color="#8bd5ca"
type="line"
/>
<TrendChart
title="New Words Learned (cumulative)"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined (cumulative)"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched (cumulative)"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart
title="Sessions (cumulative)"
data={data.progress.sessions}
color="#b7bdf8"
type="line"
/>
<TrendChart
title="Lookups (cumulative)"
data={data.progress.lookups}
color="#f5bde6"
type="line"
/>
<SectionHeader>Efficiency</SectionHeader>
<TrendChart
title="Reading Speed (words / min)"
data={data.ratios.readingSpeed}
color="#a6da95"
type="line"
/>
<TrendChart
title="Cards / Hour"
data={data.ratios.cardsPerHour}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={data.patterns.watchTimeByDayOfWeek}
@@ -176,41 +242,6 @@ export function TrendsTab() {
type="bar"
/>
<SectionHeader>Period Trends</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart
title="New Words Seen"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Library Cumulative</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
+2
View File
@@ -349,6 +349,8 @@ export interface TrendsDashboardData {
};
ratios: {
lookupsPerHundred: TrendChartPoint[];
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
};
librarySummary: LibrarySummaryRow[];
animeCumulative: {