diff --git a/stats/src/lib/session-grouping.test.ts b/stats/src/lib/session-grouping.test.ts new file mode 100644 index 00000000..feabd927 --- /dev/null +++ b/stats/src/lib/session-grouping.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SessionSummary } from '../types/stats'; +import { groupSessionsByVideo } from './session-grouping'; + +function makeSession(overrides: Partial & { sessionId: number }): SessionSummary { + return { + sessionId: overrides.sessionId, + canonicalTitle: null, + videoId: null, + animeId: null, + animeTitle: null, + startedAtMs: 1000, + endedAtMs: null, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + yomitanLookupCount: 0, + knownWordsSeen: 0, + knownWordRate: 0, + ...overrides, + }; +} + +test('empty input returns empty array', () => { + assert.deepEqual(groupSessionsByVideo([]), []); +}); + +test('two unique videoIds produce 2 singleton buckets', () => { + const sessions = [ + makeSession({ sessionId: 1, videoId: 10, startedAtMs: 1000, activeWatchedMs: 100, cardsMined: 2 }), + makeSession({ sessionId: 2, videoId: 20, startedAtMs: 2000, activeWatchedMs: 200, cardsMined: 3 }), + ]; + const buckets = groupSessionsByVideo(sessions); + assert.equal(buckets.length, 2); + const keys = buckets.map((b) => b.key).sort(); + assert.deepEqual(keys, ['v-10', 'v-20']); + for (const bucket of buckets) { + assert.equal(bucket.sessions.length, 1); + } +}); + +test('two sessions sharing a videoId collapse into 1 bucket with summed totals and most-recent representative', () => { + const older = makeSession({ sessionId: 1, videoId: 42, startedAtMs: 1000, activeWatchedMs: 300, cardsMined: 5 }); + const newer = makeSession({ sessionId: 2, videoId: 42, startedAtMs: 9000, activeWatchedMs: 500, cardsMined: 7 }); + const buckets = groupSessionsByVideo([older, newer]); + assert.equal(buckets.length, 1); + const [bucket] = buckets; + assert.equal(bucket!.key, 'v-42'); + assert.equal(bucket!.videoId, 42); + assert.equal(bucket!.sessions.length, 2); + assert.equal(bucket!.totalActiveMs, 800); + assert.equal(bucket!.totalCardsMined, 12); + assert.equal(bucket!.representativeSession.sessionId, 2); // most recent (highest startedAtMs) +}); + +test('sessions with null videoId become singleton buckets keyed by sessionId', () => { + const s1 = makeSession({ sessionId: 101, videoId: null, activeWatchedMs: 50, cardsMined: 1 }); + const s2 = makeSession({ sessionId: 202, videoId: null, activeWatchedMs: 75, cardsMined: 2 }); + const buckets = groupSessionsByVideo([s1, s2]); + assert.equal(buckets.length, 2); + const keys = buckets.map((b) => b.key).sort(); + assert.deepEqual(keys, ['s-101', 's-202']); + for (const bucket of buckets) { + assert.equal(bucket.videoId, null); + assert.equal(bucket.sessions.length, 1); + } +}); diff --git a/stats/src/lib/session-grouping.ts b/stats/src/lib/session-grouping.ts new file mode 100644 index 00000000..01e9e423 --- /dev/null +++ b/stats/src/lib/session-grouping.ts @@ -0,0 +1,43 @@ +import type { SessionSummary } from '../types/stats'; + +export interface SessionBucket { + key: string; + videoId: number | null; + sessions: SessionSummary[]; + totalActiveMs: number; + totalCardsMined: number; + representativeSession: SessionSummary; +} + +export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[] { + const byKey = new Map(); + for (const session of sessions) { + const hasVideoId = + typeof session.videoId === 'number' && + Number.isFinite(session.videoId) && + session.videoId > 0; + const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`; + const existing = byKey.get(key); + if (existing) existing.push(session); + else byKey.set(key, [session]); + } + + const buckets: SessionBucket[] = []; + for (const [key, group] of byKey) { + const sorted = [...group].sort((a, b) => b.startedAtMs - a.startedAtMs); + const representative = sorted[0]!; + buckets.push({ + key, + videoId: + typeof representative.videoId === 'number' && representative.videoId > 0 + ? representative.videoId + : null, + sessions: sorted, + totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0), + totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0), + representativeSession: representative, + }); + } + + return buckets; +}