From 6977c59691e7acfa310e12a6e99a5dac79c15ab9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 9 Apr 2026 21:58:15 -0700 Subject: [PATCH] feat(stats): build per-title librarySummary from daily rollups and sessions --- .../immersion-tracker/__tests__/query.test.ts | 116 ++++++++++++++++++ .../immersion-tracker/query-trends.ts | 85 ++++++++++++- 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index 3f9c8ef0..4291ecd7 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -3725,3 +3725,119 @@ test('deleteSession removes zero-session media from library and trends', () => { cleanupDbPath(dbPath); } }); + +test('getTrendsDashboard builds librarySummary with per-title aggregates', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', { + canonicalTitle: 'Library Summary Test', + sourcePath: '/tmp/library-summary-test.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Summary Anime', + canonicalTitle: 'Summary Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'library-summary-test.mkv', + parsedTitle: 'Summary Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + + const dayOneStart = 1_700_000_000_000; + const dayTwoStart = dayOneStart + 86_400_000; + + const sessionOne = startSessionRecord(db, videoId, dayOneStart); + const sessionTwo = startSessionRecord(db, videoId, dayTwoStart); + + for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [ + [sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8], + [sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10], + ] as const) { + stmts.telemetryInsertStmt.run( + sessionId, + `${startedAtMs + 60_000}`, + activeMs, + activeMs, + 10, + tokens, + cards, + 0, + 0, + lookups, + 0, + 0, + 0, + 0, + `${startedAtMs + 60_000}`, + `${startedAtMs + 60_000}`, + ); + + db.prepare( + ` + UPDATE imm_sessions + SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?, + lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ? + WHERE session_id = ? + `, + ).run( + `${startedAtMs + activeMs}`, + activeMs, + activeMs, + 10, + tokens, + cards, + lookups, + sessionId, + ); + } + + for (const [day, active, tokens, cards] of [ + [Math.floor(dayOneStart / 86_400_000), 30, 120, 2], + [Math.floor(dayTwoStart / 86_400_000), 45, 140, 3], + ] as const) { + db.prepare( + ` + INSERT INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_tokens_seen, total_cards + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ).run(day, videoId, 1, active, 10, tokens, cards); + } + + const dashboard = getTrendsDashboard(db, 'all', 'day'); + + assert.equal(dashboard.librarySummary.length, 1); + const row = dashboard.librarySummary[0]!; + assert.equal(row.title, 'Summary Anime'); + assert.equal(row.watchTimeMin, 75); + assert.equal(row.videos, 1); + assert.equal(row.sessions, 2); + assert.equal(row.cards, 5); + assert.equal(row.words, 260); + assert.equal(row.lookups, 18); + assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1)); + assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000)); + assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000)); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts index a50b5b02..8ea7705d 100644 --- a/src/core/services/immersion-tracker/query-trends.ts +++ b/src/core/services/immersion-tracker/query-trends.ts @@ -405,6 +405,89 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi return result; } +function buildLibrarySummary( + rollups: ImmersionSessionRollupRow[], + sessions: TrendSessionMetricRow[], + titlesByVideoId: Map, +): LibrarySummaryRow[] { + type Accum = { + watchTimeMin: number; + videos: Set; + cards: number; + words: number; + firstWatched: number; + lastWatched: number; + sessions: number; + lookups: number; + }; + + const byTitle = new Map(); + + const ensure = (title: string): Accum => { + const existing = byTitle.get(title); + if (existing) return existing; + const created: Accum = { + watchTimeMin: 0, + videos: new Set(), + cards: 0, + words: 0, + firstWatched: Number.POSITIVE_INFINITY, + lastWatched: Number.NEGATIVE_INFINITY, + sessions: 0, + lookups: 0, + }; + byTitle.set(title, created); + return created; + }; + + for (const rollup of rollups) { + if (rollup.videoId === null) continue; + const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId); + const acc = ensure(title); + acc.watchTimeMin += rollup.totalActiveMin; + acc.cards += rollup.totalCards; + acc.words += rollup.totalTokensSeen; + acc.videos.add(rollup.videoId); + if (rollup.rollupDayOrMonth < acc.firstWatched) { + acc.firstWatched = rollup.rollupDayOrMonth; + } + if (rollup.rollupDayOrMonth > acc.lastWatched) { + acc.lastWatched = rollup.rollupDayOrMonth; + } + } + + for (const session of sessions) { + const title = resolveTrendAnimeTitle(session); + if (!byTitle.has(title)) continue; + const acc = byTitle.get(title)!; + acc.sessions += 1; + acc.lookups += session.yomitanLookupCount; + } + + const rows: LibrarySummaryRow[] = []; + for (const [title, acc] of byTitle) { + if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) { + continue; + } + rows.push({ + title, + watchTimeMin: Math.round(acc.watchTimeMin), + videos: acc.videos.size, + sessions: acc.sessions, + cards: acc.cards, + words: acc.words, + lookups: acc.lookups, + lookupsPerHundred: + acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null, + firstWatched: acc.firstWatched, + lastWatched: acc.lastWatched, + }); + } + + rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title)); + return rows; +} + function getVideoAnimeTitleMap( db: DatabaseSync, videoIds: Array, @@ -716,6 +799,6 @@ export function getTrendsDashboard( watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), watchTimeByHour: buildWatchTimeByHour(sessions), }, - librarySummary: [], + librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), }; }