mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Fix stats command flow and tracking metrics regressions
- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop` - Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword - Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling - Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
This commit is contained in:
@@ -88,6 +88,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
<span className="text-ctp-peach">
|
||||
{formatNumber(getSessionDisplayWordCount(s))} tokens
|
||||
</span>
|
||||
<span className="text-ctp-green">{formatNumber(s.knownWordsSeen)} known words</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useStreakCalendar } from '../../hooks/useStreakCalendar';
|
||||
import { HeroStats } from './HeroStats';
|
||||
import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
import {
|
||||
confirmSessionDelete,
|
||||
confirmDayGroupDelete,
|
||||
@@ -122,10 +121,6 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
const summary = buildOverviewSummary(data);
|
||||
const streakData = buildStreakCalendar(calendar);
|
||||
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
|
||||
const knownWordPercent =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -141,133 +136,11 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
{!calLoading && <StreakCalendar data={streakData} />}
|
||||
</div>
|
||||
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
|
||||
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
|
||||
Lifetime totals sourced from summary tables.
|
||||
</p>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No lifetime card totals in the summary table yet. New cards mined after this fix will
|
||||
appear here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<Tooltip text="Total immersion sessions recorded across all time">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total active watch time across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{summary.allTimeMinutes < 60
|
||||
? `${summary.allTimeMinutes}m`
|
||||
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of distinct days with at least one session">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Average active watch time per session in minutes">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
|
||||
{formatNumber(summary.averageSessionMinutes)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of anime series fully completed">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
|
||||
{formatNumber(summary.totalTrackedCards)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Percentage of dictionary lookups that matched a known word">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
|
||||
{summary.lookupRate != null ? `${summary.lookupRate}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total token occurrences encountered in today's sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Tokens Today</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
||||
{formatNumber(summary.todayTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique words seen for the first time today">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
New Words Today
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
|
||||
{formatNumber(summary.newWordsToday)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique words seen for the first time this week">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
|
||||
{formatNumber(summary.newWordsThisWeek)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
|
||||
<>
|
||||
<Tooltip text="Words matched against your known-words list out of all unique words seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
Known Words
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">
|
||||
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</span>
|
||||
{knownWordPercent != null ? (
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TrackingSnapshot
|
||||
summary={summary}
|
||||
showTrackedCardNote={showTrackedCardNote}
|
||||
knownWordsSummary={knownWordsSummary}
|
||||
/>
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ interface AnimeGroup {
|
||||
totalCards: number;
|
||||
totalWords: number;
|
||||
totalActiveMs: number;
|
||||
totalKnownWords: number;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
@@ -65,6 +66,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
existing.totalCards += session.cardsMined;
|
||||
existing.totalWords += displayWordCount;
|
||||
existing.totalActiveMs += session.activeWatchedMs;
|
||||
existing.totalKnownWords += session.knownWordsSeen;
|
||||
} else {
|
||||
map.set(key, {
|
||||
key,
|
||||
@@ -75,6 +77,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
totalCards: session.cardsMined,
|
||||
totalWords: displayWordCount,
|
||||
totalActiveMs: session.activeWatchedMs,
|
||||
totalKnownWords: session.knownWordsSeen,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -173,6 +176,12 @@ function SessionItem({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -258,6 +267,12 @@ function AnimeGroupRow({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalKnownWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
@@ -327,6 +342,12 @@ function AnimeGroupRow({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
|
||||
47
stats/src/components/overview/TrackingSnapshot.test.tsx
Normal file
47
stats/src/components/overview/TrackingSnapshot.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
|
||||
const summary: OverviewSummary = {
|
||||
todayActiveMs: 0,
|
||||
todayCards: 0,
|
||||
streakDays: 0,
|
||||
allTimeMinutes: 120,
|
||||
totalTrackedCards: 9,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
averageSessionMinutes: 40,
|
||||
activeDays: 12,
|
||||
totalSessions: 15,
|
||||
lookupRate: {
|
||||
shortValue: '2.3 / 100 tokens',
|
||||
longValue: '2.3 lookups per 100 tokens',
|
||||
},
|
||||
todayTokens: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
recentWatchTime: [],
|
||||
};
|
||||
|
||||
test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Lookup Rate/);
|
||||
assert.match(markup, /2\.3 \/ 100 tokens/);
|
||||
assert.match(markup, /Lifetime Yomitan lookups normalized by total tokens seen/);
|
||||
});
|
||||
|
||||
test('TrackingSnapshot labels new words as unique headwords', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Unique headwords seen for the first time today/);
|
||||
assert.match(markup, /Unique headwords seen for the first time this week/);
|
||||
});
|
||||
151
stats/src/components/overview/TrackingSnapshot.tsx
Normal file
151
stats/src/components/overview/TrackingSnapshot.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
|
||||
interface KnownWordsSummary {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
}
|
||||
|
||||
interface TrackingSnapshotProps {
|
||||
summary: OverviewSummary;
|
||||
showTrackedCardNote?: boolean;
|
||||
knownWordsSummary: KnownWordsSummary | null;
|
||||
}
|
||||
|
||||
export function TrackingSnapshot({
|
||||
summary,
|
||||
showTrackedCardNote = false,
|
||||
knownWordsSummary,
|
||||
}: TrackingSnapshotProps) {
|
||||
const knownWordPercent =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
|
||||
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
|
||||
Lifetime totals sourced from summary tables.
|
||||
</p>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No lifetime card totals in the summary table yet. New cards mined after this fix will
|
||||
appear here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<Tooltip text="Total immersion sessions recorded across all time">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total active watch time across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{summary.allTimeMinutes < 60
|
||||
? `${summary.allTimeMinutes}m`
|
||||
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of distinct days with at least one session">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Average active watch time per session in minutes">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
|
||||
{formatNumber(summary.averageSessionMinutes)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of anime series fully completed">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
|
||||
{formatNumber(summary.totalTrackedCards)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Lifetime Yomitan lookups normalized by total tokens seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
|
||||
{summary.lookupRate?.shortValue ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total token occurrences encountered in today's sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Tokens Today</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
||||
{formatNumber(summary.todayTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique headwords seen for the first time today">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
New Words Today
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
|
||||
{formatNumber(summary.newWordsToday)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique headwords seen for the first time this week">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
|
||||
{formatNumber(summary.newWordsThisWeek)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
|
||||
<Tooltip text="Words matched against your known-words list out of all unique words seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Known Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">
|
||||
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</span>
|
||||
{knownWordPercent != null ? (
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,8 +77,6 @@ function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
|
||||
|
||||
interface RatioChartPoint {
|
||||
tsMs: number;
|
||||
knownPct: number;
|
||||
unknownPct: number;
|
||||
knownWords: number;
|
||||
unknownWords: number;
|
||||
totalWords: number;
|
||||
@@ -326,11 +324,8 @@ function RatioView({
|
||||
if (totalWords === 0) continue;
|
||||
const knownWords = Math.min(lookupKnownWords(knownWordsMap, t.linesSeen), totalWords);
|
||||
const unknownWords = totalWords - knownWords;
|
||||
const knownPct = (knownWords / totalWords) * 100;
|
||||
chartData.push({
|
||||
tsMs: t.sampleMs,
|
||||
knownPct,
|
||||
unknownPct: 100 - knownPct,
|
||||
knownWords,
|
||||
unknownWords,
|
||||
totalWords,
|
||||
@@ -400,10 +395,10 @@ function RatioView({
|
||||
<YAxis
|
||||
yAxisId="pct"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 50, 100]}
|
||||
domain={[0, finalTotal]}
|
||||
allowDataOverflow
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
tickFormatter={(v: number) => `${v.toLocaleString()}`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
@@ -415,13 +410,11 @@ function RatioView({
|
||||
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
|
||||
const d = props.payload;
|
||||
if (!d) return [_value, name];
|
||||
if (name === 'Known')
|
||||
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
|
||||
if (name === 'Unknown')
|
||||
return [
|
||||
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
|
||||
'Unknown',
|
||||
];
|
||||
if (name === 'Known words') {
|
||||
const knownPct = d.totalWords === 0 ? 0 : (d.knownWords / d.totalWords) * 100;
|
||||
return [`${d.knownWords.toLocaleString()} (${knownPct.toFixed(1)}%)`, name];
|
||||
}
|
||||
if (name === 'Unknown words') return [d.unknownWords.toLocaleString(), name];
|
||||
return [_value, name];
|
||||
}}
|
||||
itemSorter={() => -1}
|
||||
@@ -435,7 +428,7 @@ function RatioView({
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={100}
|
||||
y2={finalTotal}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
@@ -488,12 +481,12 @@ function RatioView({
|
||||
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="knownPct"
|
||||
dataKey="knownWords"
|
||||
stackId="ratio"
|
||||
stroke="#a6da95"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#knownGrad-${session.sessionId})`}
|
||||
name="Known"
|
||||
name="Known words"
|
||||
type="monotone"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
@@ -501,12 +494,12 @@ function RatioView({
|
||||
/>
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="unknownPct"
|
||||
dataKey="unknownWords"
|
||||
stackId="ratio"
|
||||
stroke="#c6a0f6"
|
||||
strokeWidth={0}
|
||||
fill={`url(#unknownGrad-${session.sessionId})`}
|
||||
name="Unknown"
|
||||
name="Unknown words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function SessionRow({
|
||||
deleteDisabled = false,
|
||||
}: SessionRowProps) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
const knownWordsSeen = session.knownWordsSeen;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
@@ -95,6 +96,12 @@ export function SessionRow({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
|
||||
@@ -35,6 +35,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
lookupCount: 10,
|
||||
lookupHits: 8,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 10,
|
||||
knownWordRate: 12.5,
|
||||
},
|
||||
];
|
||||
const rollups: DailyRollup[] = [
|
||||
@@ -66,6 +68,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
totalCards: 9,
|
||||
totalLookupCount: 100,
|
||||
totalLookupHits: 80,
|
||||
totalTokensSeen: 1000,
|
||||
totalYomitanLookupCount: 23,
|
||||
newWordsToday: 5,
|
||||
newWordsThisWeek: 20,
|
||||
},
|
||||
@@ -80,7 +84,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
assert.equal(summary.allTimeMinutes, 50);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.totalSessions, 15);
|
||||
assert.equal(summary.lookupRate, 80);
|
||||
assert.deepEqual(summary.lookupRate, {
|
||||
shortValue: '2.3 / 100 tokens',
|
||||
longValue: '2.3 lookups per 100 tokens',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||
@@ -104,6 +111,8 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
lookupCount: 1,
|
||||
lookupHits: 1,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 2,
|
||||
knownWordRate: 20,
|
||||
},
|
||||
],
|
||||
rollups: [
|
||||
@@ -132,6 +141,8 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
totalCards: 5,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
@@ -141,6 +152,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
assert.equal(summary.totalTrackedCards, 5);
|
||||
assert.equal(summary.allTimeMinutes, 120);
|
||||
assert.equal(summary.activeDays, 40);
|
||||
assert.equal(summary.lookupRate, null);
|
||||
});
|
||||
|
||||
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
VocabularyEntry,
|
||||
} from '../types/stats';
|
||||
import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters';
|
||||
import { buildLookupRateDisplay, type LookupRateDisplay } from './yomitan-lookup';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
@@ -25,7 +26,7 @@ export interface OverviewSummary {
|
||||
averageSessionMinutes: number;
|
||||
activeDays: number;
|
||||
totalSessions: number;
|
||||
lookupRate: number | null;
|
||||
lookupRate: LookupRateDisplay | null;
|
||||
todayTokens: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
@@ -181,10 +182,10 @@ export function buildOverviewSummary(
|
||||
: 0,
|
||||
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
|
||||
totalSessions: overview.hints.totalSessions ?? overview.sessions.length,
|
||||
lookupRate:
|
||||
overview.hints.totalLookupCount > 0
|
||||
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
|
||||
: null,
|
||||
lookupRate: buildLookupRateDisplay(
|
||||
overview.hints.totalYomitanLookupCount,
|
||||
overview.hints.totalTokensSeen,
|
||||
),
|
||||
todayTokens: Math.max(
|
||||
todayRow?.words ?? 0,
|
||||
sumBy(todaySessions, (session) => session.tokensSeen),
|
||||
|
||||
@@ -23,6 +23,8 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
||||
lookupCount: 3,
|
||||
lookupHits: 2,
|
||||
yomitanLookupCount: 1,
|
||||
knownWordsSeen: 6,
|
||||
knownWordRate: 25,
|
||||
},
|
||||
]}
|
||||
onDeleteSession={() => {}}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SessionDetail } from '../components/sessions/SessionDetail';
|
||||
import {
|
||||
SessionDetail,
|
||||
getKnownPctAxisMax,
|
||||
} from '../components/sessions/SessionDetail';
|
||||
import { buildSessionChartEvents } from './session-events';
|
||||
import { EventType } from '../types/stats';
|
||||
|
||||
@@ -24,6 +27,8 @@ test('SessionDetail omits the misleading new words metric', () => {
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -58,3 +63,11 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r
|
||||
);
|
||||
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
|
||||
});
|
||||
|
||||
test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
|
||||
assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
|
||||
});
|
||||
|
||||
test('getKnownPctAxisMax caps the chart top at 100%', () => {
|
||||
assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
|
||||
});
|
||||
|
||||
@@ -157,6 +157,8 @@ test('SessionRow prefers token-based word count when available', () => {
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
}}
|
||||
isExpanded={false}
|
||||
detailsId="session-7"
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface SessionSummary {
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
yomitanLookupCount: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}
|
||||
|
||||
export interface DailyRollup {
|
||||
@@ -110,8 +112,10 @@ export interface OverviewData {
|
||||
totalActiveMin: number;
|
||||
activeDays: number;
|
||||
totalCards?: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user