mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
first pass analytics redo
This commit is contained in:
1 parent
b656fd04c0
commit
45ffa39f2a
69 files changed
+6400
-1138
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user