first pass analytics redo

This commit is contained in:
George Powell committed 2026-09-22 12:15:56 -04:00
1 parent b656fd04c0
commit 45ffa39f2a
69 files changed
+6400 -1138

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
import { describe, test, expect } from 'bun:test';
import {
activePlayers,
newVsReturning,
netGrowth,
d1Return,
returnWithin,
overallReturnRate,
streakSurvival,
timeToSecondPlayHistogram,
guessHistogram,
firstTryRate,
averageGuesses,
entropy,
repeatIntervals,
bookCoverage,
retentionByGuessBucket,
weekdayHourHeatmap,
cohortTriangle,
dailyCompletionsSeries
} from '$lib/server/stats/metrics';
import type { CompletionRow } from '$lib/types/stats';
function c(anonymousId: string, date: string, guessCount = 3, hour = 12): CompletionRow {
return {
anonymousId,
date,
guessCount,
guesses: null,
completedAt: new Date(`${date}T${String(hour).padStart(2, '0')}:00:00Z`)
};
}
// A: 3-day streak. B: plays day 1 and 3. C: one play. D: one play later.
const fixture: CompletionRow[] = [
c('A', '2026-09-01'),
c('A', '2026-09-02'),
c('A', '2026-09-03'),
c('B', '2026-09-01'),
c('B', '2026-09-03'),
c('C', '2026-09-02', 1),
c('D', '2026-09-05', 6)
];
describe('activePlayers', () => {
test('window of 1 is just that day', () => {
expect(activePlayers(fixture, '2026-09-03', 1)).toBe(2); // A, B
});
test('window includes the trailing days', () => {
expect(activePlayers(fixture, '2026-09-03', 3)).toBe(3); // A, B, C
});
test('empty window returns 0', () => {
expect(activePlayers(fixture, '2026-08-01', 1)).toBe(0);
});
});
describe('newVsReturning', () => {
test('day one is all new', () => {
expect(newVsReturning(fixture, '2026-09-01')).toEqual({
newPlayers: 2,
returning: 0,
returningShare: 0
});
});
test('later days are all returning', () => {
expect(newVsReturning(fixture, '2026-09-03')).toEqual({
newPlayers: 0,
returning: 2,
returningShare: 100
});
});
});
describe('return rates', () => {
test('d1Return pools the cohort of first-timers', () => {
// A first on 09-01 and returned 09-02; B first on 09-01 and did not.
expect(d1Return(fixture, '2026-09-01')).toBe(50);
});
test('returnWithin counts any return in the window', () => {
expect(returnWithin(fixture, '2026-09-01', 2)).toBe(100);
});
test('small cohorts return null when minSize is set', () => {
expect(returnWithin(fixture, '2026-09-05', 1, 3)).toBeNull();
});
test('overall return rate = players with 2+ plays', () => {
expect(overallReturnRate(fixture)).toBe(50); // A & B of A,B,C,D
});
});
describe('netGrowth', () => {
test('new + resurrected - churned over the window', () => {
// Window 09-04..09-05: D is new; A,B,C played the preceding window and stopped.
const g = netGrowth(fixture, '2026-09-05', 2, 30);
expect(g.newPlayers).toBe(1);
expect(g.churned).toBe(3);
expect(g.netGrowth).toBe(-2);
});
test('a long gap counts as resurrected', () => {
const data = [...fixture, c('E', '2026-06-01'), c('E', '2026-09-05')];
const g = netGrowth(data, '2026-09-05', 2, 30);
expect(g.resurrected).toBe(1);
});
});
describe('streaks', () => {
test('survival probability per streak length', () => {
const survival = streakSurvival(fixture);
const byLength = new Map(survival.map((s) => [s.length, s.probability]));
expect(byLength.get(1)).toBe(20); // only A continued from day 1
expect(byLength.get(2)).toBe(100); // A continued to day 3
expect(byLength.get(3)).toBe(0); // A stopped after day 3
});
});
describe('histograms', () => {
test('time to second play buckets gaps', () => {
const bins = timeToSecondPlayHistogram(fixture);
expect(bins.find((b) => b.label === '1 day')?.value).toBe(1); // A
expect(bins.find((b) => b.label === '2 days')?.value).toBe(1); // B
});
test('guess histogram collapses the tail', () => {
const bins = guessHistogram(fixture, 5);
const total = bins.reduce((a, b) => a + b.value, 0);
expect(total).toBe(fixture.length);
expect(bins[bins.length - 1].label).toBe('6+');
});
test('first try rate and average guesses', () => {
expect(firstTryRate(fixture)).toBeCloseTo(14.3, 1);
expect(typeof averageGuesses(fixture)).toBe('number');
});
});
describe('content metrics', () => {
test('entropy of a uniform distribution is log2(n)', () => {
expect(entropy([1, 1, 1, 1])).toBe(2);
expect(entropy([])).toBe(0);
});
test('repeat intervals count gaps per book', () => {
const bins = repeatIntervals([
{ date: '2026-09-01', bookId: 'GEN' },
{ date: '2026-09-03', bookId: 'GEN' },
{ date: '2026-10-01', bookId: 'EXO' },
{ date: '2026-10-05', bookId: 'EXO' }
]);
expect(bins.find((b) => b.label === '1–7d')?.value).toBe(2);
});
test('bookCoverage returns a stat for every book', () => {
const stats = bookCoverage(
[
{ date: '2026-09-01', bookId: 'GEN' },
{ date: '2026-09-02', bookId: 'GEN' }
],
fixture,
{ start: '2026-09-01', end: '2026-09-03' }
);
expect(stats).toHaveLength(66);
const gen = stats.find((s) => s.bookId === 'GEN')!;
expect(gen.days).toBe(2);
expect(gen.plays).toBe(4); // A,B on 09-01 + A,C on 09-02
});
test('retention by guess bucket is grouped', () => {
const buckets = retentionByGuessBucket(fixture);
expect(buckets).toHaveLength(5);
});
test('weekday/hour heatmap has a 7x24 grid', () => {
const grid = weekdayHourHeatmap(fixture);
expect(grid).toHaveLength(7);
expect(grid[0]).toHaveLength(24);
});
});
describe('cohortTriangle', () => {
test('week 0 is the full cohort and later weeks are subsets', () => {
const rows = cohortTriangle(fixture, {
start: '2026-08-30', // Sunday
weeks: 3,
asOf: '2026-09-15',
minSize: 1
});
expect(rows).toHaveLength(3);
expect(rows[0].size).toBeGreaterThan(0);
expect(rows[0].counts[0]).toBe(rows[0].size);
});
});
describe('dailyCompletionsSeries', () => {
test('fills gaps with zeroes and counts new players', () => {
const series = dailyCompletionsSeries(fixture, '2026-09-01', '2026-09-03');
expect(series).toHaveLength(3);
expect(series[0]).toEqual({ date: '2026-09-01', completions: 2, newPlayers: 2 });
expect(series[2]).toEqual({ date: '2026-09-03', completions: 2, newPlayers: 0 });
});
});