mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
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.
This commit is contained in:
1 parent
4d5893ce14
commit
f9aded56f8
16 files changed
+1363
-162
No files matched your search
+190
-1
@@ -17,7 +17,10 @@ import {
|
||||
retentionByGuessBucket,
|
||||
weekdayHourHeatmap,
|
||||
cohortTriangle,
|
||||
dailyCompletionsSeries
|
||||
dailyCompletionsSeries,
|
||||
activityBuckets,
|
||||
weeklyFlows,
|
||||
projectWau
|
||||
} from '$lib/server/stats/metrics';
|
||||
import type { CompletionRow } from '$lib/types/stats';
|
||||
|
||||
@@ -191,3 +194,189 @@ describe('dailyCompletionsSeries', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user