mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
- 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.
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
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();
|
||
});
|
||
});
|