mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
feat(stats): add groupSessionsByVideo helper for episode rollups
Pure TS helper that buckets SessionSummary[] by videoId for the Sessions tab collapsible UI (Task 8). Null videoIds become singleton buckets keyed by sessionId. Covered by 4 node:test cases.
This commit is contained in:
72
stats/src/lib/session-grouping.test.ts
Normal file
72
stats/src/lib/session-grouping.test.ts
Normal file
@@ -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<SessionSummary> & { 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
43
stats/src/lib/session-grouping.ts
Normal file
43
stats/src/lib/session-grouping.ts
Normal file
@@ -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<string, SessionSummary[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user