mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -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:
@@ -260,6 +260,12 @@ function createMockTracker(
|
||||
totalActiveMin: 120,
|
||||
totalCards: 0,
|
||||
activeDays: 7,
|
||||
totalTokensSeen: 80,
|
||||
totalLookupCount: 5,
|
||||
totalLookupHits: 4,
|
||||
totalYomitanLookupCount: 5,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async () => [],
|
||||
getSessionEvents: async () => [],
|
||||
@@ -337,6 +343,8 @@ describe('stats server API routes', () => {
|
||||
assert.equal(body.hints.totalAnimeCompleted, 0);
|
||||
assert.equal(body.hints.totalActiveMin, 120);
|
||||
assert.equal(body.hints.activeDays, 7);
|
||||
assert.equal(body.hints.totalTokensSeen, 80);
|
||||
assert.equal(body.hints.totalYomitanLookupCount, 5);
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions returns session list', async () => {
|
||||
@@ -347,6 +355,39 @@ describe('stats server API routes', () => {
|
||||
assert.ok(Array.isArray(body));
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions enriches each session with known-word metrics when cache exists', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const cachePath = path.join(dir, 'known-words.json');
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
words: ['する'],
|
||||
}),
|
||||
);
|
||||
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getSessionWordsByLine: async (sessionId: number) =>
|
||||
sessionId === 1
|
||||
? [
|
||||
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
|
||||
{ lineIndex: 2, headword: '未知', occurrenceCount: 1 },
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
{ knownWordCachePath: cachePath },
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/sessions?limit=5');
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
const first = body[0];
|
||||
assert.equal(first.knownWordsSeen, 2);
|
||||
assert.equal(first.knownWordRate, 2.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
|
||||
let seenSessionId = 0;
|
||||
let seenLimit = 0;
|
||||
|
||||
@@ -365,6 +365,12 @@ export class ImmersionTrackerService {
|
||||
totalActiveMin: number;
|
||||
totalCards: number;
|
||||
activeDays: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
}> {
|
||||
return getQueryHints(this.db);
|
||||
}
|
||||
|
||||
@@ -409,6 +409,28 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||
insert.run(10, 2, 1, 11, 0, 0, 3);
|
||||
insert.run(9, 1, 1, 10, 0, 0, 1);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/query-hints.mkv', {
|
||||
canonicalTitle: 'Query Hints Episode',
|
||||
sourcePath: '/tmp/query-hints.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
status = 2,
|
||||
tokens_seen = ?,
|
||||
yomitan_lookup_count = ?,
|
||||
lookup_count = ?,
|
||||
lookup_hits = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(1_060_000, 120, 8, 11, 7, 1_060_000, sessionId);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.totalSessions, 4);
|
||||
assert.equal(hints.totalCards, 2);
|
||||
@@ -416,6 +438,52 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||
assert.equal(hints.activeDays, 9);
|
||||
assert.equal(hints.totalEpisodesWatched, 11);
|
||||
assert.equal(hints.totalAnimeCompleted, 22);
|
||||
assert.equal(hints.totalTokensSeen, 120);
|
||||
assert.equal(hints.totalYomitanLookupCount, 8);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const now = new Date();
|
||||
const todayStartSec =
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
||||
const oneHourAgo = todayStartSec + 3_600;
|
||||
const twoDaysAgo = todayStartSec - 2 * 86_400;
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.newWordsToday, 1);
|
||||
assert.equal(hints.newWordsThisWeek, 2);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
|
||||
@@ -480,8 +480,10 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
totalActiveMin: number;
|
||||
totalCards: number;
|
||||
activeDays: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
} {
|
||||
@@ -556,18 +558,30 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(SUM(COALESCE(t.tokens_seen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
|
||||
COALESCE(SUM(COALESCE(t.lookup_count, s.lookup_count, 0)), 0) AS totalLookupCount,
|
||||
COALESCE(SUM(COALESCE(t.lookup_hits, s.lookup_hits, 0)), 0) AS totalLookupHits
|
||||
COALESCE(SUM(COALESCE(t.lookup_hits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||
COALESCE(SUM(COALESCE(t.yomitan_lookup_count, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT session_id, MAX(lookup_count) AS lookup_count, MAX(lookup_hits) AS lookup_hits
|
||||
SELECT
|
||||
session_id,
|
||||
MAX(tokens_seen) AS tokens_seen,
|
||||
MAX(lookup_count) AS lookup_count,
|
||||
MAX(lookup_hits) AS lookup_hits,
|
||||
MAX(yomitan_lookup_count) AS yomitan_lookup_count
|
||||
FROM imm_session_telemetry
|
||||
GROUP BY session_id
|
||||
) t ON t.session_id = s.session_id
|
||||
WHERE s.ended_at_ms IS NOT NULL
|
||||
`,
|
||||
)
|
||||
.get() as { totalLookupCount: number; totalLookupHits: number } | null;
|
||||
.get() as {
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
} | null;
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
@@ -579,8 +593,10 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
totalActiveMin,
|
||||
totalCards,
|
||||
activeDays,
|
||||
totalTokensSeen: Number(lookupTotals?.totalTokensSeen ?? 0),
|
||||
totalLookupCount: Number(lookupTotals?.totalLookupCount ?? 0),
|
||||
totalLookupHits: Number(lookupTotals?.totalLookupHits ?? 0),
|
||||
totalYomitanLookupCount: Number(lookupTotals?.totalYomitanLookupCount ?? 0),
|
||||
...getNewWordCounts(db),
|
||||
};
|
||||
}
|
||||
@@ -593,11 +609,20 @@ function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsTh
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
WITH headword_first_seen AS (
|
||||
SELECT
|
||||
headword,
|
||||
MIN(first_seen) AS first_seen
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
AND headword IS NOT NULL
|
||||
AND headword != ''
|
||||
GROUP BY headword
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
FROM headword_first_seen
|
||||
`,
|
||||
)
|
||||
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null;
|
||||
|
||||
@@ -234,6 +234,8 @@ export interface SessionSummaryQueryRow {
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
yomitanLookupCount: number;
|
||||
knownWordsSeen?: number;
|
||||
knownWordRate?: number;
|
||||
}
|
||||
|
||||
export interface LifetimeGlobalRow {
|
||||
|
||||
@@ -140,8 +140,10 @@ function createFakeImmersionTracker(
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
@@ -359,8 +361,10 @@ test('registerIpcHandlers returns empty stats overview shape without a tracker',
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
@@ -397,8 +401,10 @@ test('registerIpcHandlers validates and clamps stats request limits', async () =
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
@@ -472,6 +478,12 @@ test('registerIpcHandlers requests the full timeline when no limit is provided',
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit?: number) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
|
||||
@@ -85,6 +85,12 @@ export interface IpcServiceDeps {
|
||||
activeDays: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
}>;
|
||||
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
@@ -486,8 +492,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
|
||||
@@ -358,6 +358,59 @@ test('macOS keeps visible overlay hidden while tracker is not initialized yet',
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
||||
const { window } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
let lastLoadingOsdAtMs: number | null = null;
|
||||
let nowMs = 1_000;
|
||||
const hiddenTracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
};
|
||||
const trackedTracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
const run = (windowTracker: WindowTrackerStub) =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: windowTracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastLoadingOsdAtMs = nowMs;
|
||||
},
|
||||
} as never);
|
||||
|
||||
run(hiddenTracker);
|
||||
run(trackedTracker);
|
||||
|
||||
nowMs = 2_000;
|
||||
run(hiddenTracker);
|
||||
run(trackedTracker);
|
||||
|
||||
nowMs = 6_500;
|
||||
run(hiddenTracker);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
|
||||
});
|
||||
|
||||
test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => {
|
||||
const calls: string[] = [];
|
||||
setVisibleOverlayVisible({
|
||||
@@ -373,10 +426,12 @@ test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly'
|
||||
assert.deepEqual(calls, ['state:true', 'update']);
|
||||
});
|
||||
|
||||
test('macOS loading OSD can show again after overlay is hidden and retried', () => {
|
||||
test('macOS explicit hide resets loading OSD suppression before retry', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
let lastLoadingOsdAtMs: number | null = null;
|
||||
let nowMs = 1_000;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
@@ -406,8 +461,17 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastLoadingOsdAtMs = nowMs;
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastLoadingOsdAtMs = null;
|
||||
},
|
||||
} as never);
|
||||
|
||||
nowMs = 1_500;
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: false,
|
||||
mainWindow: window as never,
|
||||
@@ -424,6 +488,9 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastLoadingOsdAtMs = null;
|
||||
},
|
||||
} as never);
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -454,6 +521,14 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastLoadingOsdAtMs = nowMs;
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastLoadingOsdAtMs = null;
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
|
||||
|
||||
@@ -17,6 +17,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
isMacOSPlatform?: boolean;
|
||||
isWindowsPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
shouldShowOverlayLoadingOsd?: () => boolean;
|
||||
markOverlayLoadingOsdShown?: () => void;
|
||||
resetOverlayLoadingOsdSuppression?: () => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
}): void {
|
||||
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
|
||||
@@ -39,8 +42,20 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
}
|
||||
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
||||
return;
|
||||
}
|
||||
args.showOverlayLoadingOsd('Overlay loading...');
|
||||
args.markOverlayLoadingOsdShown?.();
|
||||
};
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.resetOverlayLoadingOsdSuppression?.();
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
@@ -63,9 +78,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -81,9 +94,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
|
||||
@@ -87,6 +87,62 @@ function countKnownWords(
|
||||
return { totalUniqueWords: headwords.length, knownWordCount };
|
||||
}
|
||||
|
||||
function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number {
|
||||
if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
async function enrichSessionsWithKnownWordMetrics(
|
||||
tracker: ImmersionTrackerService,
|
||||
sessions: Array<{
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
}>,
|
||||
knownWordsCachePath?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}>
|
||||
> {
|
||||
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
||||
if (!knownWordsSet) {
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const enriched = await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
let knownWordsSeen = 0;
|
||||
try {
|
||||
const wordsByLine = await tracker.getSessionWordsByLine(session.sessionId);
|
||||
for (const row of wordsByLine) {
|
||||
if (knownWordsSet.has(row.headword)) {
|
||||
knownWordsSeen += row.occurrenceCount;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
knownWordsSeen = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
knownWordsSeen,
|
||||
knownWordRate: toKnownWordRate(knownWordsSeen, session.tokensSeen),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export interface StatsServerConfig {
|
||||
port: number;
|
||||
staticDir: string; // Path to stats/dist/
|
||||
@@ -182,11 +238,16 @@ export function createStatsApp(
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/api/stats/overview', async (c) => {
|
||||
const [sessions, rollups, hints] = await Promise.all([
|
||||
const [rawSessions, rollups, hints] = await Promise.all([
|
||||
tracker.getSessionSummaries(5),
|
||||
tracker.getDailyRollups(14),
|
||||
tracker.getQueryHints(),
|
||||
]);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, rollups, hints });
|
||||
});
|
||||
|
||||
@@ -230,7 +291,12 @@ export function createStatsApp(
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const sessions = await tracker.getSessionSummaries(limit);
|
||||
const rawSessions = await tracker.getSessionSummaries(limit);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json(sessions);
|
||||
});
|
||||
|
||||
@@ -353,11 +419,16 @@ export function createStatsApp(
|
||||
app.get('/api/stats/media/:videoId', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.json(null, 400);
|
||||
const [detail, sessions, rollups] = await Promise.all([
|
||||
const [detail, rawSessions, rollups] = await Promise.all([
|
||||
tracker.getMediaDetail(videoId),
|
||||
tracker.getMediaSessions(videoId, 100),
|
||||
tracker.getMediaDailyRollups(videoId, 90),
|
||||
]);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ detail, sessions, rollups });
|
||||
});
|
||||
|
||||
@@ -529,9 +600,14 @@ export function createStatsApp(
|
||||
app.get('/api/stats/episode/:videoId/detail', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 400);
|
||||
const sessions = await tracker.getEpisodeSessions(videoId);
|
||||
const rawSessions = await tracker.getEpisodeSessions(videoId);
|
||||
const words = await tracker.getEpisodeWords(videoId);
|
||||
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, words, cardEvents });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { BaseWindowTracker } from '../window-trackers';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { updateVisibleOverlayVisibility } from '../core/services';
|
||||
|
||||
const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
|
||||
|
||||
export interface OverlayVisibilityRuntimeDeps {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
@@ -29,6 +31,8 @@ export interface OverlayVisibilityRuntimeService {
|
||||
export function createOverlayVisibilityRuntimeService(
|
||||
deps: OverlayVisibilityRuntimeDeps,
|
||||
): OverlayVisibilityRuntimeService {
|
||||
let lastOverlayLoadingOsdAtMs: number | null = null;
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -50,6 +54,15 @@ export function createOverlayVisibilityRuntimeService(
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastOverlayLoadingOsdAtMs === null ||
|
||||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastOverlayLoadingOsdAtMs = Date.now();
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
},
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -75,6 +75,29 @@ test('stats cli command starts tracker, server, browser, and writes success resp
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command respects stats.autoOpenBrowser=false', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6969, autoOpenBrowser: false },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'ensureStatsServerStarted',
|
||||
'info:Stats dashboard available at http://127.0.0.1:6969',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:6969' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command starts background daemon without opening browser', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
|
||||
Reference in New Issue
Block a user