Files
bibdle/tests/stats-metrics.test.ts
George Powell f9aded56f8 feat(stats): add activity buckets, WAU projection, and shared community stats
- Add a weekly vs monthly players chart to Pulse, counted over whole
  completed weeks (Mon-Sun) and calendar months instead of rolling windows.
- Add a Projection section below Pulse that estimates future weekly players
  three ways (flow, regression, seasonal) with a shaded likely range.
- Pull the public community stats into one shared component used by both the
  public page and the admin console, and show yesterday's verse on the
  public page.
- Show "Resurrected" instead of "Churned" in the segment mix chart, since
  churned only ever grows and flattens the rest.
- Make full-width charts fill their container and line the hover cursor up
  correctly; show 3 months of new players per day.
- Hide the verse mix lab for now.
2026-09-30 17:00:48 -04:00

383 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
activityBuckets,
weeklyFlows,
projectWau
} 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 });
});
});
describe('activityBuckets', () => {
// 2026-09-10 is a Thursday, so its ISO week (Mon 09-07 .. Sun 09-13) is
// still in progress: the most recent completed week ends 2026-09-06 (W36).
const asOf = '2026-09-10';
test('weeks are ISO Monday–Sunday and stop at the last completed week', () => {
const rows: CompletionRow[] = [
c('mon', '2026-08-31'), // first day of the week
c('sun', '2026-09-06') // last day of the week
];
const weeks = activityBuckets(rows, asOf, 'week');
expect(weeks).toHaveLength(1);
expect(weeks[0].date).toBe('2026-09-06');
expect(weeks[0].label).toBe('W36');
expect(weeks[0].wau).toBe(2);
});
test('spans every completed week back to the first week with data', () => {
const rows: CompletionRow[] = [
c('p1', '2026-08-20'),
c('p2', '2026-08-27'),
c('p3', '2026-09-03')
];
const weeks = activityBuckets(rows, asOf, 'week');
expect(weeks.map((b) => b.date)).toEqual(['2026-08-23', '2026-08-30', '2026-09-06']);
expect(weeks.map((b) => b.label)).toEqual(['W34', 'W35', 'W36']);
expect(weeks.map((b) => b.wau)).toEqual([1, 1, 1]);
// The trailing 30-day window reaches further back, so it grows per bucket.
expect(weeks.map((b) => b.mau)).toEqual([1, 2, 3]);
});
test('excludes the in-progress week so a partial period cannot drag the series down', () => {
const rows: CompletionRow[] = [c('current', '2026-09-08'), c('done', '2026-09-04')];
const weeks = activityBuckets(rows, asOf, 'week');
expect(weeks).toHaveLength(1);
expect(weeks[0].date).toBe('2026-09-06');
expect(weeks[0].wau).toBe(1);
});
test('monthly buckets are whole calendar months up to the last completed one', () => {
const rows: CompletionRow[] = [
c('jun', '2026-06-01'),
c('jul', '2026-07-15'),
c('aug', '2026-08-31')
];
const months = activityBuckets(rows, asOf, 'month');
expect(months.map((b) => b.date)).toEqual(['2026-06-30', '2026-07-31', '2026-08-31']);
expect(months.map((b) => b.label)).toEqual(['Jun', 'Jul', 'Aug']);
// A whole calendar month, not a trailing 30-day window: Jun 1 is included.
expect(months[0].mau).toBe(1);
});
test('a month ending on the as-of day counts as completed', () => {
const rows: CompletionRow[] = [c('a', '2026-09-01'), c('b', '2026-09-30')];
const months = activityBuckets(rows, '2026-09-30', 'month');
expect(months.map((b) => b.date)).toEqual(['2026-09-30']);
expect(months[0].mau).toBe(2);
});
test('adds the week-year only when the range spans week-years', () => {
const rows: CompletionRow[] = [c('a', '2025-12-01'), c('b', '2026-01-15')];
const weeks = activityBuckets(rows, '2026-02-10', 'week');
expect(weeks[0].label).toBe("W49 '25");
expect(weeks.at(-1)?.label).toBe("W6 '26");
});
test('adds the year to month labels when the range spans years', () => {
const rows: CompletionRow[] = [c('a', '2025-11-01'), c('b', '2026-01-15')];
const months = activityBuckets(rows, '2026-02-10', 'month');
expect(months.map((b) => b.label)).toEqual(['Nov 25', 'Dec 25', 'Jan 26']);
});
test('labels January with its year even inside a single-year range', () => {
const rows: CompletionRow[] = [c('a', '2026-01-01'), c('b', '2026-03-15')];
const months = activityBuckets(rows, '2026-04-15', 'month');
expect(months.map((b) => b.label)).toEqual(['Jan 26', 'Feb', 'Mar']);
});
test('returns nothing when every period with data is still in progress', () => {
const rows: CompletionRow[] = [c('a', '2026-09-08')];
expect(activityBuckets(rows, asOf, 'month')).toEqual([]);
});
});
describe('weeklyFlows / projectWau', () => {
// Three consecutive completed ISO weeks ending Sun 08-23, 08-30, 09-06.
// p2/p3 return, p4/p5 arrive: 2 retained and 1 inflow each week.
const rows: CompletionRow[] = [
c('p1', '2026-08-18'),
c('p2', '2026-08-18'),
c('p3', '2026-08-18'),
c('p2', '2026-08-25'),
c('p3', '2026-08-25'),
c('p4', '2026-08-25'),
c('p3', '2026-09-01'),
c('p4', '2026-09-01'),
c('p5', '2026-09-01')
];
const asOf = '2026-09-10';
test('weeklyFlows accounts for retained vs inflow players', () => {
const flows = weeklyFlows(rows, asOf);
expect(flows.map((f) => f.end)).toEqual(['2026-08-23', '2026-08-30', '2026-09-06']);
expect(flows.map((f) => f.wau)).toEqual([3, 3, 3]);
expect(flows.map((f) => f.retained)).toEqual([0, 2, 2]);
expect(flows.map((f) => f.inflow)).toEqual([3, 1, 1]);
expect(flows.map((f) => f.newPlayers)).toEqual([3, 1, 1]);
expect(flows.map((f) => f.resurrected)).toEqual([0, 0, 0]);
});
test('the flow model holds a flat base steady instead of overshooting', () => {
const projection = projectWau(rows, asOf, 4);
expect(projection).not.toBeNull();
expect(projection?.sampleWeeks).toBe(3);
expect(projection?.retention).toBeCloseTo(0.667, 3);
expect(projection?.inflow).toBe(1);
// retention 2/3 * 3 + 1 = 3 every week.
expect(projection?.flow.map((p) => p.value)).toEqual([3, 3, 3, 3]);
expect(projection?.flow).toHaveLength(4);
expect(projection?.flow[0].label).toBe('W37');
});
test('every model returns a bracketed band', () => {
const projection = projectWau(rows, asOf, 4);
for (const key of ['flow', 'regression', 'seasonal'] as const) {
expect(projection?.[key]).toHaveLength(4);
for (const point of projection?.[key] ?? []) {
expect(point.low).toBeLessThanOrEqual(point.value);
expect(point.value).toBeLessThanOrEqual(point.high);
}
}
});
test('fits regression and seasonal models once there is enough history', () => {
const start = new Date('2026-01-05T00:00:00Z'); // Monday
const rows: CompletionRow[] = [];
for (let w = 0; w < 30; w++) {
const weekStart = new Date(start);
weekStart.setUTCDate(weekStart.getUTCDate() + w * 7);
for (let p = 0; p < 3; p++) {
const d = new Date(weekStart);
d.setUTCDate(d.getUTCDate() + ((w + p) % 7));
rows.push(c(`core-${p}`, d.toISOString().slice(0, 10)));
}
for (let p = 0; p < 2; p++) {
const d = new Date(weekStart);
d.setUTCDate(d.getUTCDate() + ((w + p + 3) % 7));
rows.push(c(`new-${w}-${p}`, d.toISOString().slice(0, 10)));
}
}
const after = new Date(start);
after.setUTCDate(after.getUTCDate() + 30 * 7);
const projection = projectWau(rows, after.toISOString().slice(0, 10), 4);
expect(projection?.sampleWeeks).toBe(30);
expect(projection?.regression).toHaveLength(4);
expect(projection?.seasonal).toHaveLength(4);
expect(projection?.retention).toBeGreaterThan(0);
expect(projection?.retention).toBeLessThanOrEqual(1);
});
test('the corrected regression does not collapse toward zero', () => {
const start = new Date('2026-01-05T00:00:00Z'); // Monday
const rows: CompletionRow[] = [];
for (let w = 0; w < 30; w++) {
const weekStart = new Date(start);
weekStart.setUTCDate(weekStart.getUTCDate() + w * 7);
for (let p = 0; p < 4; p++) {
const d = new Date(weekStart);
d.setUTCDate(d.getUTCDate() + ((w + p) % 7));
rows.push(c(`core-${p}`, d.toISOString().slice(0, 10)));
}
}
const after = new Date(start);
after.setUTCDate(after.getUTCDate() + 30 * 7);
const projection = projectWau(rows, after.toISOString().slice(0, 10), 4);
const last = projection?.regression.at(-1)?.value ?? 0;
// A stable 4-player base should not decay away.
expect(last).toBeGreaterThanOrEqual(3);
});
test('needs at least three completed weeks', () => {
const few = [c('a', '2026-09-01'), c('b', '2026-09-02')];
expect(projectWau(few, asOf)).toBeNull();
});
});