mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 00:15:27 -07:00
Compare commits
2
Commits
e0e9c182ee
...
acf25a3065
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf25a3065
|
||
|
|
36ec5c1b93
|
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ When YouTube channel metadata is available, the Library tab groups videos by cre
|
|||||||
|
|
||||||
#### Trends
|
#### 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -761,6 +761,10 @@ 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.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.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(
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface TrendsDashboardQueryResult {
|
|||||||
};
|
};
|
||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: TrendChartPoint[];
|
lookupsPerHundred: TrendChartPoint[];
|
||||||
|
cardsPerHour: TrendChartPoint[];
|
||||||
|
readingSpeed: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: TrendPerAnimePoint[];
|
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[] {
|
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||||
const totals = new Array(7).fill(0);
|
const totals = new Array(7).fill(0);
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@@ -675,6 +697,7 @@ export function getTrendsDashboard(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
||||||
|
const efficiency = buildEfficiencyRates(aggregatedRows);
|
||||||
const activity = {
|
const activity = {
|
||||||
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||||
cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
|
cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
|
||||||
@@ -724,6 +747,8 @@ export function getTrendsDashboard(
|
|||||||
},
|
},
|
||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||||
|
cardsPerHour: efficiency.cardsPerHour,
|
||||||
|
readingSpeed: efficiency.readingSpeed,
|
||||||
},
|
},
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function TrendsTab() {
|
|||||||
onGroupByChange={setGroupBy}
|
onGroupByChange={setGroupBy}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<SectionHeader>Activity</SectionHeader>
|
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
title="Watch Time (min)"
|
title="Watch Time (min)"
|
||||||
data={data.activity.watchTime}
|
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="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" />
|
||||||
|
|
||||||
|
<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
|
<TrendChart
|
||||||
title="Watch Time by Day of Week (min)"
|
title="Watch Time by Day of Week (min)"
|
||||||
data={data.patterns.watchTimeByDayOfWeek}
|
data={data.patterns.watchTimeByDayOfWeek}
|
||||||
@@ -176,41 +242,6 @@ export function TrendsTab() {
|
|||||||
type="bar"
|
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>
|
<SectionHeader>Library — Cumulative</SectionHeader>
|
||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={animeTitles}
|
animeTitles={animeTitles}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import type { KanjiEntry } from '../../types/stats';
|
import type { KanjiEntry } from '../../types/stats';
|
||||||
|
import { formatNumber } from '../../lib/formatters';
|
||||||
|
|
||||||
interface KanjiBreakdownProps {
|
interface KanjiBreakdownProps {
|
||||||
kanji: KanjiEntry[];
|
kanji: KanjiEntry[];
|
||||||
@@ -6,34 +8,75 @@ interface KanjiBreakdownProps {
|
|||||||
onSelectKanji?: (entry: KanjiEntry) => void;
|
onSelectKanji?: (entry: KanjiEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Heat scale from rare (cool) to very frequent (warm). Catppuccin Macchiato.
|
||||||
|
const FREQ_TIERS = [
|
||||||
|
{ min: 0.85, color: 'text-ctp-peach', swatch: 'bg-ctp-peach', label: 'Very frequent' },
|
||||||
|
{ min: 0.6, color: 'text-ctp-yellow', swatch: 'bg-ctp-yellow', label: 'Frequent' },
|
||||||
|
{ min: 0.35, color: 'text-ctp-green', swatch: 'bg-ctp-green', label: 'Common' },
|
||||||
|
{ min: 0.15, color: 'text-ctp-teal', swatch: 'bg-ctp-teal', label: 'Occasional' },
|
||||||
|
{ min: 0, color: 'text-ctp-subtext0', swatch: 'bg-ctp-subtext0', label: 'Rare' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function tierFor(intensity: number) {
|
||||||
|
return FREQ_TIERS.find((tier) => intensity >= tier.min) ?? FREQ_TIERS[FREQ_TIERS.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
export function KanjiBreakdown({
|
export function KanjiBreakdown({
|
||||||
kanji,
|
kanji,
|
||||||
selectedKanjiId = null,
|
selectedKanjiId = null,
|
||||||
onSelectKanji,
|
onSelectKanji,
|
||||||
}: KanjiBreakdownProps) {
|
}: KanjiBreakdownProps) {
|
||||||
if (kanji.length === 0) return null;
|
const { totalOccurrences, maxLogFreq } = useMemo(() => {
|
||||||
|
let total = 0;
|
||||||
|
let maxFreq = 1;
|
||||||
|
for (const entry of kanji) {
|
||||||
|
total += entry.frequency;
|
||||||
|
if (entry.frequency > maxFreq) maxFreq = entry.frequency;
|
||||||
|
}
|
||||||
|
return { totalOccurrences: total, maxLogFreq: Math.log(maxFreq + 1) };
|
||||||
|
}, [kanji]);
|
||||||
|
|
||||||
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
|
if (kanji.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-ctp-text">
|
||||||
|
Kanji Encountered
|
||||||
|
<span className="ml-2 font-normal text-ctp-subtext0">
|
||||||
|
{formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-ctp-subtext0">
|
||||||
|
<span>rare</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[...FREQ_TIERS].reverse().map((tier) => (
|
||||||
|
<span
|
||||||
|
key={tier.label}
|
||||||
|
className={`h-2 w-2 rounded-full ${tier.swatch}`}
|
||||||
|
title={tier.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span>frequent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{kanji.map((k) => {
|
{kanji.map((k) => {
|
||||||
const ratio = k.frequency / maxFreq;
|
// Log scale keeps the heavily-skewed frequency distribution readable.
|
||||||
const opacity = Math.max(0.3, ratio);
|
const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0;
|
||||||
|
const tier = tierFor(intensity);
|
||||||
|
const selected = selectedKanjiId === k.kanjiId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={k.kanji}
|
key={k.kanji}
|
||||||
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
|
className={`cursor-pointer rounded-md px-1.5 py-0.5 text-xl leading-none font-medium transition-colors duration-150 ${tier.color} ${
|
||||||
selectedKanjiId === k.kanjiId
|
selected ? 'bg-ctp-surface2 ring-1 ring-ctp-lavender' : 'hover:bg-ctp-surface1'
|
||||||
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
|
|
||||||
: 'hover:bg-ctp-surface1/80'
|
|
||||||
}`}
|
}`}
|
||||||
style={{ opacity }}
|
title={`${k.kanji} — seen ${formatNumber(k.frequency)}×`}
|
||||||
title={`${k.kanji} — seen ${k.frequency}x`}
|
|
||||||
aria-label={`${k.kanji} — seen ${k.frequency} times`}
|
aria-label={`${k.kanji} — seen ${k.frequency} times`}
|
||||||
|
aria-pressed={selected}
|
||||||
onClick={() => onSelectKanji?.(k)}
|
onClick={() => onSelectKanji?.(k)}
|
||||||
>
|
>
|
||||||
{k.kanji}
|
{k.kanji}
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ export interface TrendsDashboardData {
|
|||||||
};
|
};
|
||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: TrendChartPoint[];
|
lookupsPerHundred: TrendChartPoint[];
|
||||||
|
cardsPerHour: TrendChartPoint[];
|
||||||
|
readingSpeed: TrendChartPoint[];
|
||||||
};
|
};
|
||||||
librarySummary: LibrarySummaryRow[];
|
librarySummary: LibrarySummaryRow[];
|
||||||
animeCumulative: {
|
animeCumulative: {
|
||||||
|
|||||||
Reference in New Issue
Block a user