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:
2026-03-19 15:46:52 -07:00
parent 274b0619ac
commit f2d6c70019
37 changed files with 1093 additions and 190 deletions

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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

View 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/);
});

View 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>
);
}

View File

@@ -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}
/>

View File

@@ -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' : ''}`}

View File

@@ -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', () => {

View File

@@ -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),

View File

@@ -23,6 +23,8 @@ test('MediaSessionList renders expandable session rows with delete affordance',
lookupCount: 3,
lookupHits: 2,
yomitanLookupCount: 1,
knownWordsSeen: 6,
knownWordRate: 25,
},
]}
onDeleteSession={() => {}}

View File

@@ -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);
});

View File

@@ -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"

View File

@@ -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;
};