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:
George Powell committed 2026-09-30 17:00:48 -04:00
1 parent 4d5893ce14
commit f9aded56f8
16 files changed
+1363 -162

No files matched your search

+190 -1
View File
@@ -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();
});
});